Merge branch 'dev' of github.com:Tria-plc/emaui into Refactor

Resolved by unifying on the lib/data AdvancedTable (PR #10) as the canonical
table component: kept its API plus teammate i18n/feature work, kept the
folder-per-table structure (index.tsx + columns.tsx + actions.tsx) across all
27 tables, removed the parallel lib/table implementation, and fixed
pre-existing compile breaks in ExamDetailPage/QuestionAssigner/RecordResultModal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Nati
2026-08-13 11:31:27 +00:00
182 changed files with 14181 additions and 8141 deletions

View File

@@ -1,7 +1,7 @@
import { Badge, Button, Text } from '@mantine/core';
import { IconUserCheck } from '@tabler/icons-react';
import type { TFunction } from 'i18next';
import type { AdvancedTableColumn } from '@ema-platform/ui';
import type { AdvancedColumn } from '@ema-platform/ui';
import type { AttendanceStatus, ExamRegistration } from '../../types/exam';
const ATTENDANCE_COLOR: Record<AttendanceStatus, string> = {
@@ -25,68 +25,64 @@ export const candidateName = (registration: ExamRegistration) =>
export function examCandidateColumns(
t: TFunction,
handlers: { onRecord: (registration: ExamRegistration) => void },
): AdvancedTableColumn<ExamRegistration>[] {
): AdvancedColumn<ExamRegistration>[] {
return [
{
key: 'admission',
header: t('exam.candidates.admission'),
render: (registration) => (
cell: ({ row }) => (
<Text fz="sm" ff="monospace" fw={600}>
{registration.admissionNumber}
{row.original.admissionNumber}
</Text>
),
},
{
key: 'name',
header: t('exam.candidates.name'),
render: (registration) => <Text fz="sm">{candidateName(registration)}</Text>,
cell: ({ row }) => <Text fz="sm">{candidateName(row.original)}</Text>,
},
{
key: 'attempt',
header: t('exam.candidates.attempt'),
render: (registration) => (
cell: ({ row }) => (
<Badge
size="sm"
variant="light"
color={registration.kind === 'RETAKE' ? 'orange' : 'blue'}
color={row.original.kind === 'RETAKE' ? 'orange' : 'blue'}
>
{registration.kind === 'RETAKE'
? t('exam.candidates.retake', { n: registration.attemptNumber })
{row.original.kind === 'RETAKE'
? t('exam.candidates.retake', { n: row.original.attemptNumber })
: t('exam.candidates.firstSitting')}
</Badge>
),
},
{
key: 'attendance',
header: t('exam.candidates.attendance'),
render: (registration) => (
cell: ({ row }) => (
<Badge
size="sm"
variant="light"
color={ATTENDANCE_COLOR[registration.attendanceStatus] ?? 'gray'}
color={ATTENDANCE_COLOR[row.original.attendanceStatus] ?? 'gray'}
>
{t(`exam.attendance.${registration.attendanceStatus}`)}
{t(`exam.attendance.${row.original.attendanceStatus}`)}
</Badge>
),
},
{
key: 'remark',
header: t('exam.candidates.remark'),
render: (registration) => (
cell: ({ row }) => (
<Text fz="xs" c="dimmed" maw={220} lineClamp={2}>
{registration.attendanceRemark ?? '—'}
{row.original.attendanceRemark ?? '—'}
</Text>
),
},
{
key: 'record',
header: '',
render: (registration) => (
label: t('exam.candidates.record'),
align: 'right',
cell: ({ row }) => (
<Button
size="compact-xs"
variant="light"
leftSection={<IconUserCheck size={12} />}
onClick={() => handlers.onRecord(registration)}
onClick={() => handlers.onRecord(row.original)}
>
{t('exam.candidates.record')}
</Button>

View File

@@ -13,7 +13,7 @@ import {
Title,
} from '@mantine/core';
import { IconInfoCircle } from '@tabler/icons-react';
import { AdvancedTable, notify } from '@ema-platform/ui';
import { AdvancedTable, notify, useServerTable } from '@ema-platform/ui';
import { extractErrorMessage } from '@ema-platform/api';
import {
useGetExamRegistrationsQuery,
@@ -46,6 +46,7 @@ export function ExamCandidatesPanel({ examId }: { examId: string }) {
const [target, setTarget] = useState<ExamRegistration | null>(null);
const [status, setStatus] = useState<AttendanceStatus>('PRESENT');
const [remark, setRemark] = useState('');
const table = useServerTable();
const startRecording = (registration: ExamRegistration) => {
setTarget(registration);
@@ -81,6 +82,8 @@ export function ExamCandidatesPanel({ examId }: { examId: string }) {
// register; the endpoint refuses them and there is nothing to show.
if (isError) return null;
const paged = table.paginate(registrations ?? []);
return (
<Paper withBorder radius="lg" p="lg">
<Title order={5} mb="md">
@@ -92,10 +95,14 @@ export function ExamCandidatesPanel({ examId }: { examId: string }) {
</Alert>
) : (
<AdvancedTable
tableName={t('exam.candidates.section')}
columns={examCandidateColumns(t, { onRecord: startRecording })}
data={registrations ?? []}
rowKey={(registration) => registration.id}
onRefresh={refetch}
data={paged.rows}
itemCount={paged.itemCount}
pageIndex={paged.pageIndex}
onPageChange={table.setPageIndex}
pageSize={table.pageSize}
refresh={refetch}
/>
)}

View File

@@ -1,7 +1,7 @@
import { Badge, Button, Text } from '@mantine/core';
import { IconAlertTriangle } from '@tabler/icons-react';
import type { TFunction } from 'i18next';
import type { AdvancedTableColumn } from '@ema-platform/ui';
import type { AdvancedColumn } from '@ema-platform/ui';
import type { ExamIncident, ExamIncidentStatus } from '../../types/exam';
const STATUS_COLOR: Record<ExamIncidentStatus, string> = {
@@ -13,75 +13,72 @@ const STATUS_COLOR: Record<ExamIncidentStatus, string> = {
export function examIncidentColumns(
t: TFunction,
showDate: (date: string | null | undefined) => string,
handlers: { onResolve: (incident: ExamIncident) => void },
): AdvancedTableColumn<ExamIncident>[] {
): AdvancedColumn<ExamIncident>[] {
return [
{
key: 'type',
header: t('exam.incidents.type'),
render: (incident) => (
cell: ({ row }) => (
<Badge size="sm" variant="light" color="orange">
{t(`exam.incidentType.${incident.type}`)}
{t(`exam.incidentType.${row.original.type}`)}
</Badge>
),
},
{
key: 'candidate',
header: t('exam.incidents.candidate'),
render: (incident) => (
cell: ({ row }) => (
<Text fz="xs">
{incident.registration?.admissionNumber ?? t('exam.incidents.wholeRoom')}
{row.original.registration?.admissionNumber ?? t('exam.incidents.wholeRoom')}
</Text>
),
},
{
key: 'description',
header: t('exam.incidents.description'),
render: (incident) => (
cell: ({ row }) => (
<>
<Text fz="xs" maw={260} lineClamp={2}>
{incident.description}
{row.original.description}
</Text>
{incident.resolution && (
{row.original.resolution && (
<Text fz="xs" c="dimmed" maw={260} lineClamp={2}>
{incident.resolution}
{row.original.resolution}
</Text>
)}
</>
),
},
{
key: 'occurred',
header: t('exam.incidents.occurred'),
render: (incident) => <Text fz="xs">{incident.occurredAt?.slice(0, 10)}</Text>,
cell: ({ row }) => <Text fz="xs">{showDate(row.original.occurredAt)}</Text>,
},
{
key: 'status',
header: t('exam.incidents.status'),
render: (incident) => (
cell: ({ row }) => (
<Badge
size="sm"
variant="light"
color={STATUS_COLOR[incident.status] ?? 'gray'}
color={STATUS_COLOR[row.original.status] ?? 'gray'}
>
{t(`exam.incidentStatus.${incident.status}`)}
{t(`exam.incidentStatus.${row.original.status}`)}
</Badge>
),
},
{
key: 'resolve',
header: '',
render: (incident) =>
(incident.status === 'OPEN' || incident.status === 'UNDER_REVIEW') && (
label: t('exam.incidents.resolve'),
align: 'right',
cell: ({ row }) =>
row.original.status === 'OPEN' || row.original.status === 'UNDER_REVIEW' ? (
<Button
size="compact-xs"
variant="light"
leftSection={<IconAlertTriangle size={12} />}
onClick={() => handlers.onResolve(incident)}
onClick={() => handlers.onResolve(row.original)}
>
{t('exam.incidents.resolve')}
</Button>
),
) : null,
},
];
}

View File

@@ -13,7 +13,8 @@ import {
Title,
} from '@mantine/core';
import { IconInfoCircle, IconPlus } from '@tabler/icons-react';
import { AdvancedTable, notify } from '@ema-platform/ui';
import { AdvancedTable, notify, useServerTable } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
import { extractErrorMessage } from '@ema-platform/api';
import {
useGetExamIncidentsQuery,
@@ -39,7 +40,9 @@ const TYPES: ExamIncidentType[] = [
*/
export function ExamIncidentsPanel({ examId }: { examId: string }) {
const { t } = useTranslation();
const showDate = useDateDisplayer();
const { data: incidents, isError, refetch } = useGetExamIncidentsQuery(examId);
const table = useServerTable();
const { data: registrations } = useGetExamRegistrationsQuery(examId);
const [recordIncident, { isLoading: isFiling }] = useRecordIncidentMutation();
const [resolveIncident, { isLoading: isResolving }] = useResolveIncidentMutation();
@@ -113,6 +116,8 @@ export function ExamIncidentsPanel({ examId }: { examId: string }) {
// endpoint refuses them, so there is nothing to render.
if (isError) return null;
const paged = table.paginate(incidents ?? []);
return (
<Paper withBorder radius="lg" p="lg">
<Group justify="space-between" mb="md">
@@ -133,11 +138,15 @@ export function ExamIncidentsPanel({ examId }: { examId: string }) {
{t('exam.incidents.none')}
</Alert>
) : (
<AdvancedTable
columns={examIncidentColumns(t, { onResolve: startResolve })}
data={incidents ?? []}
rowKey={(incident) => incident.id}
onRefresh={refetch}
<AdvancedTable<ExamIncident>
tableName={t('exam.incidents.section')}
columns={examIncidentColumns(t, showDate, { onResolve: startResolve })}
data={paged.rows}
itemCount={paged.itemCount}
pageIndex={paged.pageIndex}
onPageChange={table.setPageIndex}
pageSize={table.pageSize}
refresh={refetch}
/>
)}

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { Dispatch, SetStateAction, useState } from "react";
import {
Paper,
Group,
@@ -10,16 +10,19 @@ import {
Checkbox,
Box,
Button,
} from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { IconSearch } from '@tabler/icons-react';
import type { QuestionBrief } from '../types/exam';
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import { IconSearch } from "@tabler/icons-react";
import type { QuestionBrief } from "../types/exam";
type actionTypes = "add" | "remove";
interface QuestionAssignerProps {
available: QuestionBrief[];
assigned: QuestionBrief[];
onChange: (assigned: QuestionBrief[]) => void;
mode?: 'manual' | 'random';
mode?: "manual" | "random";
actions?: Dispatch<SetStateAction<actionTypes | undefined>>;
}
function QuestionList({
@@ -38,11 +41,13 @@ function QuestionList({
label: string;
}) {
const { t, i18n } = useTranslation();
const locale = i18n.language as 'en' | 'am';
const placeholder = t('exam.assigner.search');
const locale = i18n.language as "en" | "am";
const placeholder = t("exam.assigner.search");
return (
<Box style={{ flex: 1, minWidth: 0 }}>
<Text fz="xs" fw={600} c="dimmed" mb={4}>{label} ({items.length})</Text>
<Text fz="xs" fw={600} c="dimmed" mb={4}>
{label} ({items.length})
</Text>
<Paper withBorder radius="md">
<Group p="sm" pb={0}>
<TextInput
@@ -57,7 +62,9 @@ function QuestionList({
<ScrollArea h={280} p="sm" pt="xs">
<Stack gap={4}>
{items.length === 0 && (
<Text fz="xs" c="dimmed" ta="center" py="xl">{t('exam.assigner.noQuestions')}</Text>
<Text fz="xs" c="dimmed" ta="center" py="xl">
{t("exam.assigner.noQuestions")}
</Text>
)}
{items.map((q) => (
<Paper
@@ -66,19 +73,37 @@ function QuestionList({
p="xs"
radius="sm"
style={{
cursor: 'pointer',
borderColor: selected.has(q.id) ? 'var(--mantine-color-blue-5)' : undefined,
background: selected.has(q.id) ? 'var(--mantine-color-blue-0)' : undefined,
cursor: "pointer",
borderColor: selected.has(q.id)
? "var(--mantine-color-blue-5)"
: undefined,
background: selected.has(q.id)
? "var(--mantine-color-blue-0)"
: undefined,
}}
onClick={() => onToggle(q.id)}
>
<Group gap="sm" wrap="nowrap">
<Checkbox checked={selected.has(q.id)} onChange={() => onToggle(q.id)} size="xs" />
<Checkbox
checked={selected.has(q.id)}
onChange={() => onToggle(q.id)}
size="xs"
/>
<div style={{ flex: 1, minWidth: 0 }}>
<Text fz="xs" lineClamp={2}>{q.title[locale]}</Text>
<Text fz="xs" lineClamp={2}>
{q.title[locale]}
</Text>
<Group gap={4} mt={2}>
<Badge size="xs" variant="light" color={q.form === 'ESSAY' ? 'blue' : 'violet'}>{q.form}</Badge>
<Badge size="xs" variant="light" color="gray">{q.points} pts</Badge>
<Badge
size="xs"
variant="light"
color={q.form === "ESSAY" ? "blue" : "violet"}
>
{q.form}
</Badge>
<Badge size="xs" variant="light" color="gray">
{q.points} pts
</Badge>
</Group>
</div>
</Group>
@@ -91,49 +116,71 @@ function QuestionList({
);
}
export function QuestionAssigner({ available, assigned, onChange, mode = 'manual' }: QuestionAssignerProps) {
export function QuestionAssigner({
available,
assigned,
onChange,
mode = "manual",
actions,
}: QuestionAssignerProps) {
const { t } = useTranslation();
const [searchLeft, setSearchLeft] = useState('');
const [searchRight, setSearchRight] = useState('');
const [searchLeft, setSearchLeft] = useState("");
const [searchRight, setSearchRight] = useState("");
const [selectedLeft, setSelectedLeft] = useState<Set<string>>(new Set());
const [selectedRight, setSelectedRight] = useState<Set<string>>(new Set());
const assignedIds = new Set(assigned.map((q) => q.id));
const filteredAvailable = available.filter(
(q) => !assignedIds.has(q.id) && (q.title.en.toLowerCase().includes(searchLeft.toLowerCase()) || q.title.am.includes(searchLeft))
(q) =>
!assignedIds.has(q.id) &&
(q.title.en.toLowerCase().includes(searchLeft.toLowerCase()) ||
q.title.am.includes(searchLeft)),
);
const filteredAssigned = assigned.filter(
(q) => q.title.en.toLowerCase().includes(searchRight.toLowerCase()) || q.title.am.includes(searchRight)
(q) =>
q.title.en.toLowerCase().includes(searchRight.toLowerCase()) ||
q.title.am.includes(searchRight),
);
const assignSelected = () => {
const toAssign = available.filter((q) => selectedLeft.has(q.id));
onChange([...assigned, ...toAssign]);
actions?.("add");
setSelectedLeft(new Set());
};
const removeSelected = () => {
onChange(assigned.filter((q) => !selectedRight.has(q.id)));
actions?.("remove");
setSelectedRight(new Set());
};
return (
<Stack gap="sm">
{mode === 'manual' && <Text fz="sm" fw={500}>{t('exam.assigner.title')}</Text>}
{mode === 'random' && <Text fz="sm" fw={500}>{t('exam.assigner.assignedTitle')}</Text>}
{mode === "manual" && (
<Text fz="sm" fw={500}>
{t("exam.assigner.title")}
</Text>
)}
{mode === "random" && (
<Text fz="sm" fw={500}>
{t("exam.assigner.assignedTitle")}
</Text>
)}
<Group gap="sm" align="stretch" wrap="nowrap">
{mode === 'manual' && (
{mode === "manual" && (
<QuestionList
items={filteredAvailable}
selected={selectedLeft}
onToggle={(id) => {
const next = new Set(selectedLeft);
if (next.has(id)) next.delete(id); else next.add(id);
if (next.has(id)) next.delete(id);
else next.add(id);
setSelectedLeft(next);
}}
search={searchLeft}
onSearchChange={setSearchLeft}
label={t('exam.assigner.available')}
label={t("exam.assigner.available")}
/>
)}
<QuestionList
@@ -141,32 +188,43 @@ export function QuestionAssigner({ available, assigned, onChange, mode = 'manual
selected={selectedRight}
onToggle={(id) => {
const next = new Set(selectedRight);
if (next.has(id)) next.delete(id); else next.add(id);
if (next.has(id)) next.delete(id);
else next.add(id);
setSelectedRight(next);
}}
search={searchRight}
onSearchChange={setSearchRight}
label={t('exam.assigner.assigned')}
label={t("exam.assigner.assigned")}
/>
</Group>
{mode === 'manual' && (
{mode === "manual" && (
<Group gap="sm" justify="center">
{selectedLeft.size > 0 && (
<Button size="xs" variant="light" onClick={assignSelected}>
{t('exam.assigner.assignSelected', { count: selectedLeft.size })}
{t("exam.assigner.assignSelected", { count: selectedLeft.size })}
</Button>
)}
{selectedRight.size > 0 && (
<Button size="xs" variant="light" color="red" onClick={removeSelected}>
{t('exam.assigner.removeSelected', { count: selectedRight.size })}
<Button
size="xs"
variant="light"
color="red"
onClick={removeSelected}
>
{t("exam.assigner.removeSelected", { count: selectedRight.size })}
</Button>
)}
</Group>
)}
{mode === 'random' && selectedRight.size > 0 && (
{mode === "random" && selectedRight.size > 0 && (
<Group gap="sm" justify="center">
<Button size="xs" variant="light" color="red" onClick={removeSelected}>
{t('exam.assigner.removeSelected', { count: selectedRight.size })}
<Button
size="xs"
variant="light"
color="red"
onClick={removeSelected}
>
{t("exam.assigner.removeSelected", { count: selectedRight.size })}
</Button>
</Group>
)}

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useRef, useMemo } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useState, useEffect, useRef, useMemo } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
Stack,
Title,
@@ -22,9 +22,9 @@ import {
ThemeIcon,
Box,
rem,
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { useTranslation } from 'react-i18next';
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { useTranslation } from "react-i18next";
import {
IconArrowLeft,
IconPrinter,
@@ -39,7 +39,7 @@ import {
IconCheck,
IconX,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { ModalFooter, notify, useErrorHandler } from '@ema-platform/ui';
import { extractErrorMessage } from '@ema-platform/api';
import {
useGetExamQuery,
@@ -56,32 +56,50 @@ import { ExamIncidentsPanel } from '../components/ExamIncidentsPanel';
import type { ExamStatus, QuestionBrief } from '../types/exam';
const STATUS_COLOR: Record<string, string> = {
PENDING: 'gray', ACTIVE: 'blue', COMPLETED: 'teal',
CANCELLED: 'red', POSTPONED: 'orange', PUBLISHED: 'green',
PENDING: "gray",
ACTIVE: "blue",
COMPLETED: "teal",
CANCELLED: "red",
POSTPONED: "orange",
PUBLISHED: "green",
};
const FORM_LABEL: Record<string, string> = { ESSAY: 'Essay', CHOICE: 'Choice' };
const TYPE_LABEL: Record<string, string> = { WRITTEN: 'Written', ORAL: 'Oral' };
const ADMIN_LABEL: Record<string, string> = { OFFLINE: 'Offline', ONLINE: 'Online' };
const EVAL_LABEL: Record<string, string> = { SUM: 'Sum', AVERAGE: 'Average', PERCENTAGE: 'Percentage' };
const FORM_LABEL: Record<string, string> = { ESSAY: "Essay", CHOICE: "Choice" };
const TYPE_LABEL: Record<string, string> = { WRITTEN: "Written", ORAL: "Oral" };
const ADMIN_LABEL: Record<string, string> = {
OFFLINE: "Offline",
ONLINE: "Online",
};
const EVAL_LABEL: Record<string, string> = {
SUM: "Sum",
AVERAGE: "Average",
PERCENTAGE: "Percentage",
};
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div>
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>{label}</Text>
<Text fz="sm" fw={500}>{value || '—'}</Text>
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>
{label}
</Text>
<Text fz="sm" fw={500}>
{value || "—"}
</Text>
</div>
);
}
export function ExamDetailPage() {
const { t, i18n } = useTranslation();
const locale = i18n.language as 'en' | 'am';
const locale = i18n.language as "en" | "am";
const { handleError } = useErrorHandler();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const printRef = useRef<HTMLDivElement>(null);
const [recordOpened, { open: openRecord, close: closeRecord }] = useDisclosure(false);
const [assignOpened, { open: openAssign, close: closeAssign }] = useDisclosure(false);
const [recordOpened, { open: openRecord, close: closeRecord }] =
useDisclosure(false);
const [assignOpened, { open: openAssign, close: closeAssign }] =
useDisclosure(false);
const [draftQuestions, setDraftQuestions] = useState<QuestionBrief[]>([]);
const [randomCount, setRandomCount] = useState(5);
const [updateExam] = useUpdateExamMutation();
@@ -109,12 +127,26 @@ export function ExamDetailPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [allQuestions, exam?.certificationId, exam?.form]);
if (isLoading) return <Center py="xl"><Loader /></Center>;
if (isLoading)
return (
<Center py="xl">
<Loader />
</Center>
);
if (isError || !exam) {
return (
<Stack gap="md">
<Button variant="subtle" leftSection={<IconArrowLeft size={15} />} w="fit-content" onClick={() => navigate('/exams')}>{t('exam.backToExams')}</Button>
<Alert color="red" icon={<IconInfoCircle size={17} />}>{t('exam.notFound')}</Alert>
<Button
variant="subtle"
leftSection={<IconArrowLeft size={15} />}
w="fit-content"
onClick={() => navigate("/exams")}
>
{t("exam.backToExams")}
</Button>
<Alert color="red" icon={<IconInfoCircle size={17} />}>
{t("exam.notFound")}
</Alert>
</Stack>
);
}
@@ -161,42 +193,51 @@ export function ExamDetailPage() {
);
}
};
const handlePrint = async () => {
const total = (exam.questions ?? []).reduce((s, q) => s + Number(q.points), 0);
const total = (exam.questions ?? []).reduce(
(s, q) => s + Number(q.points),
0,
);
if (total < Number(exam.cuttingPoint)) {
notify.error(`Total question marks (${total}) is less than the passing mark (${exam.cuttingPoint}). Add more questions or adjust the cutting point before printing.`);
notify.error(
`Total question marks (${total}) is less than the passing mark (${exam.cuttingPoint}). Add more questions or adjust the cutting point before printing.`,
);
return;
}
const printWindow = window.open('', '_blank');
const printWindow = window.open("", "_blank");
if (!printWindow) return;
let logoBase64 = '';
let logoBase64 = "";
try {
const resp = await fetch('/ema-logo.png');
const resp = await fetch("/ema-logo.png");
const blob = await resp.blob();
logoBase64 = await new Promise<string>((resolve) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.readAsDataURL(blob);
});
} catch { /* logo not available */ }
} catch {
/* logo not available */
}
const qMap = new Map(allQuestions.map((qq) => [qq.id, qq]));
const qHtml = (exam.questions ?? []).map((q, i) => {
const full = qMap.get(q.id);
const titleStr = q.title[locale] || q.title.en;
const descStr = full?.description?.[locale] || full?.description?.en || '';
return `
const qHtml = (exam.questions ?? [])
.map((q, i) => {
const full = qMap.get(q.id);
const titleStr = q.title[locale] || q.title.en;
const descStr =
full?.description?.[locale] || full?.description?.en || "";
return `
<div style="margin-bottom: 24px; page-break-inside: avoid;">
<p style="font-weight: 700; margin-bottom: 4px; font-size: 13px;">Question ${i + 1} (${q.points} pts — ${FORM_LABEL[q.form] ?? q.form})</p>
<p style="margin: 0 0 4px 0; font-size: 14px; line-height: 1.5;">${titleStr}</p>
${descStr ? `<p style="margin: 0 0 8px 0; font-size: 12px; color: #555; line-height: 1.4;">${descStr}</p>` : ''}
${q.form === 'ESSAY' ? '<div style="border-bottom: 1px dashed #ccc; height: 80px; margin-bottom: 12px;"></div>'.repeat(3) : ''}
${q.form === 'CHOICE' ? ['A. ______', 'B. ______', 'C. ______', 'D. ______'].map(l => `<p style="margin: 4px 0; font-size: 13px;">${l}</p>`).join('') : ''}
${descStr ? `<p style="margin: 0 0 8px 0; font-size: 12px; color: #555; line-height: 1.4;">${descStr}</p>` : ""}
${q.form === "ESSAY" ? '<div style="border-bottom: 1px dashed #ccc; height: 80px; margin-bottom: 12px;"></div>'.repeat(3) : ""}
${q.form === "CHOICE" ? ["A. ______", "B. ______", "C. ______", "D. ______"].map((l) => `<p style="margin: 4px 0; font-size: 13px;">${l}</p>`).join("") : ""}
</div>`;
}).join('');
})
.join("");
printWindow.document.write(`
<html><head><title>${exam.title[locale] || exam.title.en}</title>
@@ -211,13 +252,13 @@ export function ExamDetailPage() {
@media print { @page { margin: 20mm; } body { -webkit-print-color-adjust: exact; } }
</style></head><body>
<div class="header">
${logoBase64 ? `<img src="${logoBase64}" alt="Logo" class="header-logo" />` : ''}
${logoBase64 ? `<img src="${logoBase64}" alt="Logo" class="header-logo" />` : ""}
<h1>${exam.title[locale] || exam.title.en}</h1>
<p>Date: ${exam.date} | Venue: ${exam.venue}</p>
<p>Form: ${FORM_LABEL[exam.form]} | Type: ${TYPE_LABEL[exam.type]} | Time Allowed: ${exam.givenTime ? `${exam.givenTime.days}d ${exam.givenTime.hours}h ${exam.givenTime.minutes}m` : 'N/A'}</p>
<p>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?.[locale] ? `<div class="directions"><strong>Directions:</strong> ${exam.direction[locale]}</div>` : ''}
${exam.direction?.[locale] ? `<div class="directions"><strong>Directions:</strong> ${exam.direction[locale]}</div>` : ""}
${qHtml}
<div style="margin-top: 40px; border-top: 1px solid #ccc; padding-top: 12px; font-size: 12px; color: #888; text-align: center;">
Generated by EMA — Ethiopian Maritime Authority
@@ -229,15 +270,25 @@ export function ExamDetailPage() {
setTimeout(() => printWindow.print(), 500);
};
const totalPoints = (exam.questions ?? []).reduce((s, q) => s + Number(q.points), 0);
const certName = exam.certification?.name?.[locale] ?? certifications.find((c) => c.id === exam.certificationId)?.name?.[locale] ?? '—';
const totalPoints = (exam.questions ?? []).reduce(
(s, q) => s + Number(q.points),
0,
);
const certName =
exam.certification?.name?.[locale] ??
certifications.find((c) => c.id === exam.certificationId)?.name?.[locale] ??
"—";
return (
<Stack gap="md" ref={printRef}>
{/* Header */}
<Group justify="space-between">
<Group gap="sm">
<ActionIcon variant="subtle" size="lg" onClick={() => navigate('/exams')}>
<ActionIcon
variant="subtle"
size="lg"
onClick={() => navigate("/exams")}
>
<IconArrowLeft size={18} />
</ActionIcon>
<div>
@@ -245,41 +296,93 @@ export function ExamDetailPage() {
</div>
</Group>
<Group gap="sm">
<Button variant="light" leftSection={<IconPrinter size={15} />} onClick={handlePrint} size="sm">
{t('exam.print')}
<Button
variant="light"
leftSection={<IconPrinter size={15} />}
onClick={handlePrint}
size="sm"
>
{t("exam.print")}
</Button>
<Button leftSection={<IconPlus size={15} />} onClick={openRecord} size="sm">
{t('exam.recordResult')}
<Button
leftSection={<IconPlus size={15} />}
onClick={openRecord}
size="sm"
>
{t("exam.recordResult")}
</Button>
</Group>
</Group>
{/* Status badge */}
<Badge size="lg" variant="light" color={STATUS_COLOR[exam.status]} style={{ width: 'fit-content' }}>
<Badge
size="lg"
variant="light"
color={STATUS_COLOR[exam.status]}
style={{ width: "fit-content" }}
>
{t(`exam.status.${exam.status}`)}
</Badge>
{/* Exam Info */}
<Paper withBorder radius="lg" p="lg">
<Title order={5} mb="md">{t('exam.detail.title')}</Title>
<Title order={5} mb="md">
{t("exam.detail.title")}
</Title>
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
<InfoRow label={t('exam.detail.certification')} value={certName} />
<InfoRow label={t('exam.detail.type')} value={t(`exam.type.${exam.type}`)} />
<InfoRow label={t('exam.detail.form')} value={t(`exam.formType.${exam.form}`)} />
<InfoRow label={t('exam.detail.venue')} value={exam.venue} />
<InfoRow label={t('exam.detail.date')} value={exam.date} />
<InfoRow label={t('exam.detail.administration')} value={t(`exam.admin.${exam.administrationMethod}`)} />
<InfoRow label={t('exam.detail.evaluation')} value={t(`exam.eval.${exam.evaluationMethod}`)} />
<InfoRow label={t('exam.detail.selection')} value={t(`exam.selection.${exam.selectionMethod}`)} />
<InfoRow label={t('exam.detail.timeAllowed')} value={exam.givenTime ? `${exam.givenTime.days}d ${exam.givenTime.hours}h ${exam.givenTime.minutes}m` : '—'} />
<InfoRow label={t('exam.detail.passMark')} value={String(exam.cuttingPoint)} />
<InfoRow label={t('exam.detail.totalPoints')} value={String(totalPoints)} />
<InfoRow label={t('exam.detail.questions')} value={String((exam.questions ?? []).length)} />
<InfoRow label={t("exam.detail.certification")} value={certName} />
<InfoRow
label={t("exam.detail.type")}
value={t(`exam.type.${exam.type}`)}
/>
<InfoRow
label={t("exam.detail.form")}
value={t(`exam.formType.${exam.form}`)}
/>
<InfoRow label={t("exam.detail.venue")} value={exam.venue} />
<InfoRow label={t("exam.detail.date")} value={exam.date} />
<InfoRow
label={t("exam.detail.administration")}
value={t(`exam.admin.${exam.administrationMethod}`)}
/>
<InfoRow
label={t("exam.detail.evaluation")}
value={t(`exam.eval.${exam.evaluationMethod}`)}
/>
<InfoRow
label={t("exam.detail.selection")}
value={t(`exam.selection.${exam.selectionMethod}`)}
/>
<InfoRow
label={t("exam.detail.timeAllowed")}
value={
exam.givenTime
? `${exam.givenTime.days}d ${exam.givenTime.hours}h ${exam.givenTime.minutes}m`
: "—"
}
/>
<InfoRow
label={t("exam.detail.passMark")}
value={String(exam.cuttingPoint)}
/>
<InfoRow
label={t("exam.detail.totalPoints")}
value={String(totalPoints)}
/>
<InfoRow
label={t("exam.detail.questions")}
value={String((exam.questions ?? []).length)}
/>
</SimpleGrid>
{(exam.direction?.en || exam.direction?.am) && (
<>
<Divider my="md" />
<InfoRow label={t('exam.detail.directions')} value={[exam.direction?.en, exam.direction?.am].filter(Boolean).join(' / ')} />
<InfoRow
label={t("exam.detail.directions")}
value={[exam.direction?.en, exam.direction?.am]
.filter(Boolean)
.join(" / ")}
/>
</>
)}
</Paper>
@@ -287,24 +390,41 @@ export function ExamDetailPage() {
{/* Questions */}
<Paper withBorder radius="lg" p="lg">
<Group justify="space-between" mb="md">
<Title order={5}>{t('exam.detail.questionsSection', { pts: totalPoints })}</Title>
<Button variant="light" size="xs" leftSection={<IconPlus size={14} />} onClick={openAssignModal}>
{t('exam.manageQuestions')}
<Title order={5}>
{t("exam.detail.questionsSection", { pts: totalPoints })}
</Title>
<Button
variant="light"
size="xs"
leftSection={<IconPlus size={14} />}
onClick={openAssignModal}
>
{t("exam.manageQuestions")}
</Button>
</Group>
{(exam.questions ?? []).length === 0 ? (
<Alert color="gray" icon={<IconInfoCircle size={16} />}>
{t('exam.noQuestionsAssigned')}
{t("exam.noQuestionsAssigned")}
</Alert>
) : (
<Stack gap="md">
{(exam.questions ?? []).map((q, i) => (
<Paper key={q.id} withBorder p="md" radius="md">
<Group justify="space-between" mb="xs">
<Text fz="sm" fw={700}>{t('exam.detail.questionLabel')} {i + 1}</Text>
<Text fz="sm" fw={700}>
{t("exam.detail.questionLabel")} {i + 1}
</Text>
<Group gap={4}>
<Badge size="xs" variant="light" color={q.form === 'ESSAY' ? 'blue' : 'violet'}>{t(`exam.formType.${q.form}`)}</Badge>
<Badge size="xs" variant="light" color="gray">{q.points} pts</Badge>
<Badge
size="xs"
variant="light"
color={q.form === "ESSAY" ? "blue" : "violet"}
>
{t(`exam.formType.${q.form}`)}
</Badge>
<Badge size="xs" variant="light" color="gray">
{q.points} pts
</Badge>
</Group>
</Group>
<Text fz="sm">{q.title[locale]}</Text>
@@ -321,9 +441,15 @@ export function ExamDetailPage() {
<RecordResultModal exam={exam} opened={recordOpened} onClose={closeRecord} />
{/* Question assignment modal */}
<Modal opened={assignOpened} onClose={closeAssign} title={`${t('exam.manageQuestions')}${exam.title[locale]}`} size="xl" radius="lg">
<Modal
opened={assignOpened}
onClose={closeAssign}
title={`${t("exam.manageQuestions")}${exam.title[locale]}`}
size="xl"
radius="lg"
>
<Stack gap="md">
{exam.selectionMethod === 'MANUAL' ? (
{exam.selectionMethod === "MANUAL" ? (
<>
<QuestionAssigner
available={eligibleQuestions}
@@ -331,17 +457,21 @@ export function ExamDetailPage() {
onChange={setDraftQuestions}
mode="manual"
/>
<Group justify="flex-end">
<Button variant="default" onClick={closeAssign} size="sm">{t('exam.cancel')}</Button>
<Button onClick={handleAssign} size="sm" loading={isAssigning}>{t('exam.saveAssignments')}</Button>
</Group>
<ModalFooter>
<Button variant="default" onClick={closeAssign} size="sm">
{t("exam.cancel")}
</Button>
<Button onClick={handleAssign} size="sm" loading={isAssigning}>
{t("exam.saveAssignments")}
</Button>
</ModalFooter>
</>
) : (
<>
<Text fz="sm" c="dimmed">{t('exam.randomHintServer')}</Text>
<Group gap="sm">
<NumberInput
placeholder={t('exam.assigner.selectCount')}
placeholder={t("exam.assigner.selectCount")}
value={randomCount}
onChange={(v) => setRandomCount(Number(v))}
min={1}
@@ -358,10 +488,14 @@ export function ExamDetailPage() {
onChange={setDraftQuestions}
mode="random"
/>
<Group justify="flex-end">
<Button variant="default" onClick={closeAssign} size="sm">{t('exam.cancel')}</Button>
<Button onClick={handleAssign} size="sm" loading={isAssigning}>{t('exam.saveAssignments')}</Button>
</Group>
<ModalFooter>
<Button variant="default" onClick={closeAssign} size="sm">
{t("exam.cancel")}
</Button>
<Button onClick={handleAssign} size="sm" loading={isAssigning}>
{t("exam.saveAssignments")}
</Button>
</ModalFooter>
</>
)}
</Stack>

View File

@@ -1,29 +1,47 @@
import { IconEdit, IconTrash } from '@tabler/icons-react';
import type { TFunction } from 'i18next';
import type { AdvancedTableAction } from '@ema-platform/ui';
import type { Exam } from '../../types/exam';
import { ActionIcon, Group } from "@mantine/core";
import { IconEdit, IconTrash, IconDetails } from "@tabler/icons-react";
import type { TFunction } from "i18next";
import type { AdvancedColumn } from "@ema-platform/ui";
import type { Exam } from "../../types/exam";
export function examColumnActions(
export function examActionsColumn(
t: TFunction,
handlers: {
onEdit: (exam: Exam) => void;
onDelete: (exam: Exam) => void;
onDetails: (exam: Exam) => void;
},
): AdvancedTableAction<Exam>[] {
return [
{
key: 'edit',
label: t('exam.update'),
color: 'blue',
icon: <IconEdit size={14} />,
onClick: handlers.onEdit,
},
{
key: 'delete',
label: t('exam.delete'),
color: 'red',
icon: <IconTrash size={14} />,
onClick: handlers.onDelete,
},
];
): AdvancedColumn<Exam> {
return {
header: t("exam.columns.actions"),
align: "right",
cell: ({ row }) => (
<Group gap="xs">
<ActionIcon
variant="subtle"
color="blue"
size="sm"
onClick={() => handlers.onEdit(row.original)}
>
<IconEdit size={14} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="red"
size="sm"
onClick={() => handlers.onDelete(row.original)}
>
<IconTrash size={14} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="red"
size="sm"
onClick={() => handlers.onDetails(row.original)}
>
<IconDetails size={14} />
</ActionIcon>
</Group>
),
};
}

View File

@@ -1,89 +1,79 @@
import { Badge, Text } from '@mantine/core';
import type { TFunction } from 'i18next';
import type { AdvancedTableColumn } from '@ema-platform/ui';
import type { Exam } from '../../types/exam';
import { Badge, Text } from "@mantine/core";
import type { TFunction } from "i18next";
import type { AdvancedColumn } from "@ema-platform/ui";
import type { Exam } from "../../types/exam";
const STATUS_COLOR: Record<string, string> = {
PENDING: 'gray',
ACTIVE: 'blue',
COMPLETED: 'teal',
CANCELLED: 'red',
POSTPONED: 'orange',
PUBLISHED: 'green',
PENDING: "gray",
ACTIVE: "blue",
COMPLETED: "teal",
CANCELLED: "red",
POSTPONED: "orange",
PUBLISHED: "green",
};
export function examColumns(
t: TFunction,
handlers: {
locale: 'en' | 'am';
getCertName: (id: string) => string;
onTitleClick: (exam: Exam) => void;
},
): AdvancedTableColumn<Exam>[] {
locale: "en" | "am",
getCertName: (id: string) => string,
onTitleClick: (exam: Exam) => void,
): AdvancedColumn<Exam>[] {
return [
{
key: 'title',
header: t('exam.columns.title'),
render: (exam) => (
header: t("exam.columns.title"),
cell: ({ row }) => (
<Text
fz="sm"
fw={500}
c="blue"
style={{ cursor: 'pointer' }}
onClick={() => handlers.onTitleClick(exam)}
style={{ cursor: "pointer" }}
onClick={() => onTitleClick(row.original)}
>
{exam.title[handlers.locale]}
{row.original.title[locale]}
</Text>
),
},
{
key: 'certification',
header: t('exam.columns.certification'),
render: (exam) => <Text fz="sm">{handlers.getCertName(exam.certificationId)}</Text>,
header: t("exam.columns.certification"),
cell: ({ row }) => <Text fz="sm">{getCertName(row.original.certificationId)}</Text>,
},
{
key: 'date',
header: t('exam.columns.date'),
render: (exam) => <Text fz="sm">{exam.date}</Text>,
header: t("exam.columns.date"),
cell: ({ row }) => <Text fz="sm">{row.original.date}</Text>,
},
{
key: 'type',
header: t('exam.columns.type'),
render: (exam) => (
<Badge size="sm" variant="light" color={exam.type === 'WRITTEN' ? 'blue' : 'orange'}>
{t(`exam.type.${exam.type}`)}
header: t("exam.columns.type"),
cell: ({ row }) => (
<Badge size="sm" variant="light" color={row.original.type === "WRITTEN" ? "blue" : "orange"}>
{t(`exam.type.${row.original.type}`)}
</Badge>
),
},
{
key: 'form',
header: t('exam.columns.form'),
render: (exam) => (
<Badge size="sm" variant="light" color={exam.form === 'ESSAY' ? 'blue' : 'violet'}>
{t(`exam.formType.${exam.form}`)}
header: t("exam.columns.form"),
cell: ({ row }) => (
<Badge size="sm" variant="light" color={row.original.form === "ESSAY" ? "blue" : "violet"}>
{t(`exam.formType.${row.original.form}`)}
</Badge>
),
},
{
key: 'venue',
header: t('exam.columns.venue'),
render: (exam) => <Text fz="sm">{exam.venue}</Text>,
header: t("exam.columns.venue"),
cell: ({ row }) => <Text fz="sm">{row.original.venue}</Text>,
},
{
key: 'questions',
header: t('exam.columns.questions'),
render: (exam) => (
header: t("exam.columns.questions"),
cell: ({ row }) => (
<Badge size="sm" variant="light" color="gray">
{exam.questions?.length ?? 0}
{row.original.questions?.length ?? 0}
</Badge>
),
},
{
key: 'status',
header: t('exam.columns.status'),
render: (exam) => (
<Badge size="sm" variant="light" color={STATUS_COLOR[exam.status]}>
{t(`exam.status.${exam.status}`)}
header: t("exam.columns.status"),
cell: ({ row }) => (
<Badge size="sm" variant="light" color={STATUS_COLOR[row.original.status]}>
{t(`exam.status.${row.original.status}`)}
</Badge>
),
},

View File

@@ -1,5 +1,5 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import {
Stack,
Title,
@@ -9,30 +9,31 @@ import {
Text,
TextInput,
Textarea,
Paper,
Loader,
Center,
Card,
Alert,
Select,
NumberInput,
Tabs,
SimpleGrid,
Divider,
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { useTranslation } from 'react-i18next';
import { IconPlus, IconInfoCircle, IconCalendar, IconClipboardList } from '@tabler/icons-react';
import { AdvancedTable, notify } from '@ema-platform/ui';
import { useGetCertificationsQuery } from '../../../certification/api/certification-api';
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { useTranslation } from "react-i18next";
import {
IconPlus,
IconInfoCircle,
IconClipboardList,
} from "@tabler/icons-react";
import { notify, useErrorHandler, AdvancedTable, useServerTable, ModalFooter, AmharicDatePicker } from "@ema-platform/ui";
import { useGetCertificationsQuery } from "../../../certification/api/certification-api";
import {
useGetExamsQuery,
useCreateExamMutation,
useUpdateExamMutation,
useDeleteExamMutation,
} from '../../api/exam-api';
import type { Exam } from '../../types/exam';
import { examColumns } from './columns';
import { examColumnActions } from './actions';
} from "../../api/exam-api";
import type { Exam } from "../../types/exam";
import { examColumns } from "./columns";
import { examActionsColumn } from "./actions";
function ExamForm({
editing,
@@ -48,100 +49,297 @@ function ExamForm({
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 [directionEn, setDirectionEn] = useState(editing?.direction?.en ?? '');
const [directionAm, setDirectionAm] = useState(editing?.direction?.am ?? '');
const [date, setDate] = useState(editing?.date ?? '');
const [certificationId, setCertificationId] = useState<string | null>(
editing?.certificationId ?? null,
);
const [titleEn, setTitleEn] = useState(editing?.title?.en ?? "");
const [titleAm, setTitleAm] = useState(editing?.title?.am ?? "");
const [directionEn, setDirectionEn] = useState(editing?.direction?.en ?? "");
const [directionAm, setDirectionAm] = useState(editing?.direction?.am ?? "");
const [date, setDate] = useState(editing?.date ?? "");
const [days, setDays] = useState(editing?.givenTime?.days ?? 0);
const [hours, setHours] = useState(editing?.givenTime?.hours ?? 0);
const [minutes, setMinutes] = useState(editing?.givenTime?.minutes ?? 0);
const [type, setType] = useState<string | null>(editing?.type ?? null);
const [form, setForm] = useState<string | null>(editing?.form ?? null);
const [venue, setVenue] = useState(editing?.venue ?? '');
const [adminMethod, setAdminMethod] = useState<string | null>(editing?.administrationMethod ?? null);
const [evalMethod, setEvalMethod] = useState<string | null>(editing?.evaluationMethod ?? null);
const [selMethod, setSelMethod] = useState<string | null>(editing?.selectionMethod ?? null);
const [cuttingPoint, setCuttingPoint] = useState<number>(editing?.cuttingPoint ?? 0);
const [venue, setVenue] = useState(editing?.venue ?? "");
const [adminMethod, setAdminMethod] = useState<string | null>(
editing?.administrationMethod ?? null,
);
const [evalMethod, setEvalMethod] = useState<string | null>(
editing?.evaluationMethod ?? null,
);
const [selMethod, setSelMethod] = useState<string | null>(
editing?.selectionMethod ?? null,
);
const [cuttingPoint, setCuttingPoint] = useState<number>(
editing?.cuttingPoint ?? 0,
);
const [status, setStatus] = useState<string | null>(editing?.status ?? null);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!certificationId || !titleEn || !titleAm || !date || !type || !form || !venue || !adminMethod || !evalMethod) {
notify.error('Please fill all required fields');
if (
!certificationId ||
!titleEn ||
!titleAm ||
!date ||
!type ||
!form ||
!venue ||
!adminMethod ||
!evalMethod
) {
notify.error("Please fill all required fields");
return;
}
onSubmit({
certificationId, titleEn, titleAm, directionEn, directionAm,
date, days, hours, minutes, type, form, venue, adminMethod, evalMethod, selMethod, cuttingPoint, status,
}, !!editing);
onSubmit(
{
certificationId,
titleEn,
titleAm,
directionEn,
directionAm,
date,
days,
hours,
minutes,
type,
form,
venue,
adminMethod,
evalMethod,
selMethod,
cuttingPoint,
status,
},
!!editing,
);
};
return (
<Paper p="md" withBorder mb="md" radius="md">
<Modal opened onClose={onCancel} title={editing ? t("exam.update") : t("exam.add")} size="xl">
<form onSubmit={handleSubmit}>
<Tabs defaultValue="basic" variant="outline" radius="md">
<Tabs.List mb="md">
<Tabs.Tab value="basic" leftSection={<IconInfoCircle size={15} />}>{t('exam.form.basicInfo')}</Tabs.Tab>
<Tabs.Tab value="settings" leftSection={<IconClipboardList size={15} />}>{t('exam.form.settings')}</Tabs.Tab>
</Tabs.List>
<Tabs defaultValue="basic" variant="outline" radius="md">
<Tabs.List mb="md">
<Tabs.Tab value="basic" leftSection={<IconInfoCircle size={15} />}>
{t("exam.form.basicInfo")}
</Tabs.Tab>
<Tabs.Tab
value="settings"
leftSection={<IconClipboardList size={15} />}
>
{t("exam.form.settings")}
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="basic">
<Stack gap="sm">
<Select label={t('exam.form.certification')} placeholder={t('exam.form.selectCertification')} data={certOptions} value={certificationId} onChange={setCertificationId} size="sm" searchable required />
<TextInput label={t('exam.form.titleEn')} placeholder={t('exam.form.titleEnPlaceholder')} value={titleEn} onChange={(e) => setTitleEn(e.currentTarget.value)} size="sm" required />
<TextInput label={t('exam.form.titleAm')} placeholder={t('exam.form.titleAmPlaceholder')} value={titleAm} onChange={(e) => setTitleAm(e.currentTarget.value)} size="sm" required />
<Textarea label={t('exam.form.directionEn')} placeholder={t('exam.form.directionEnPlaceholder')} value={directionEn} onChange={(e) => setDirectionEn(e.currentTarget.value)} size="sm" autosize minRows={2} />
<Textarea label={t('exam.form.directionAm')} placeholder={t('exam.form.directionAmPlaceholder')} value={directionAm} onChange={(e) => setDirectionAm(e.currentTarget.value)} size="sm" autosize minRows={2} />
<TextInput label={t('exam.form.examDate')} type="date" value={date} onChange={(e) => setDate(e.currentTarget.value)} size="sm" leftSection={<IconCalendar size={14} />} required />
<TextInput label={t('exam.form.venue')} placeholder={t('exam.form.venuePlaceholder')} value={venue} onChange={(e) => setVenue(e.currentTarget.value)} size="sm" required />
<Tabs.Panel value="basic">
<Stack gap="sm">
<Select
label={t("exam.form.certification")}
placeholder={t("exam.form.selectCertification")}
data={certOptions}
value={certificationId}
onChange={setCertificationId}
size="sm"
searchable
required
/>
<TextInput
label={t("exam.form.titleEn")}
placeholder={t("exam.form.titleEnPlaceholder")}
value={titleEn}
onChange={(e) => setTitleEn(e.currentTarget.value)}
size="sm"
required
/>
<TextInput
label={t("exam.form.titleAm")}
placeholder={t("exam.form.titleAmPlaceholder")}
value={titleAm}
onChange={(e) => setTitleAm(e.currentTarget.value)}
size="sm"
required
/>
<Textarea
label={t("exam.form.directionEn")}
placeholder={t("exam.form.directionEnPlaceholder")}
value={directionEn}
onChange={(e) => setDirectionEn(e.currentTarget.value)}
size="sm"
autosize
minRows={2}
/>
<Textarea
label={t("exam.form.directionAm")}
placeholder={t("exam.form.directionAmPlaceholder")}
value={directionAm}
onChange={(e) => setDirectionAm(e.currentTarget.value)}
size="sm"
autosize
minRows={2}
/>
<AmharicDatePicker
label={t("exam.form.examDate")}
value={date}
onChange={setDate}
dateFormat="date"
size="sm"
required
/>
<TextInput
label={t("exam.form.venue")}
placeholder={t("exam.form.venuePlaceholder")}
value={venue}
onChange={(e) => setVenue(e.currentTarget.value)}
size="sm"
required
/>
<Text fz="sm" fw={500}>{t('exam.form.timeAllowed')}</Text>
<Group gap="sm" grow>
<NumberInput label={t('exam.form.days')} value={days} onChange={(v) => setDays(Number(v))} min={0} size="sm" />
<NumberInput label={t('exam.form.hours')} value={hours} onChange={(v) => setHours(Number(v))} min={0} size="sm" />
<NumberInput label={t('exam.form.minutes')} value={minutes} onChange={(v) => setMinutes(Number(v))} min={0} size="sm" />
</Group>
</Stack>
</Tabs.Panel>
<Text fz="sm" fw={500}>
{t("exam.form.timeAllowed")}
</Text>
<Group gap="sm" grow>
<NumberInput
label={t("exam.form.days")}
value={days}
onChange={(v) => setDays(Number(v))}
min={0}
size="sm"
/>
<NumberInput
label={t("exam.form.hours")}
value={hours}
onChange={(v) => setHours(Number(v))}
min={0}
size="sm"
/>
<NumberInput
label={t("exam.form.minutes")}
value={minutes}
onChange={(v) => setMinutes(Number(v))}
min={0}
size="sm"
/>
</Group>
</Stack>
</Tabs.Panel>
<Tabs.Panel value="settings">
<Stack gap="sm">
<SimpleGrid cols={2} spacing="sm">
<Select label={t('exam.columns.type')} placeholder="Written or Oral" data={[{ value: 'WRITTEN', label: t('exam.form.written') }, { value: 'ORAL', label: t('exam.form.oral') }]} value={type} onChange={setType} size="sm" required />
<Select label={t('exam.columns.form')} placeholder="Essay or Choice" data={[{ value: 'ESSAY', label: t('exam.form.essay') }, { value: 'CHOICE', label: t('exam.form.choice') }]} value={form} onChange={setForm} size="sm" required />
<Select label={t('exam.detail.administration')} placeholder="Offline or Online" data={[{ value: 'OFFLINE', label: t('exam.form.offline') }, { value: 'ONLINE', label: t('exam.form.online') }]} value={adminMethod} onChange={setAdminMethod} size="sm" required />
<Select label={t('exam.detail.evaluation')} placeholder="How to compute score" data={[{ value: 'SUM', label: t('exam.form.sum') }, { value: 'AVERAGE', label: t('exam.form.average') }, { value: 'PERCENTAGE', label: t('exam.form.percentage') }]} value={evalMethod} onChange={setEvalMethod} size="sm" required />
<Select label={t('exam.detail.selection')} placeholder="Manual or Random" data={[{ value: 'MANUAL', label: t('exam.form.manual') }, { value: 'RANDOM', label: t('exam.form.random') }]} value={selMethod} onChange={setSelMethod} size="sm" />
<NumberInput label={t('exam.form.cuttingPoint')} placeholder={t('exam.form.cuttingPointPlaceholder')} value={cuttingPoint} onChange={(v) => setCuttingPoint(Number(v))} min={0} size="sm" required />
</SimpleGrid>
{editing && (
<Select label={t('exam.form.status')} placeholder={t('exam.form.statusPlaceholder')} data={[
{ value: 'PENDING', label: t('exam.form.pending') }, { value: 'ACTIVE', label: t('exam.form.active') },
{ value: 'COMPLETED', label: t('exam.form.completed') }, { value: 'CANCELLED', label: t('exam.form.cancelled') },
{ value: 'POSTPONED', label: t('exam.form.postponed') }, { value: 'PUBLISHED', label: t('exam.form.published') },
]} value={status} onChange={setStatus} size="sm" />
)}
</Stack>
</Tabs.Panel>
</Tabs>
<Tabs.Panel value="settings">
<Stack gap="sm">
<SimpleGrid cols={2} spacing="sm">
<Select
label={t("exam.columns.type")}
placeholder="Written or Oral"
data={[
{ value: "WRITTEN", label: t("exam.form.written") },
{ value: "ORAL", label: t("exam.form.oral") },
]}
value={type}
onChange={setType}
size="sm"
required
/>
<Select
label={t("exam.columns.form")}
placeholder="Essay or Choice"
data={[
{ value: "ESSAY", label: t("exam.form.essay") },
{ value: "CHOICE", label: t("exam.form.choice") },
]}
value={form}
onChange={setForm}
size="sm"
required
/>
<Select
label={t("exam.detail.administration")}
placeholder="Offline or Online"
data={[
{ value: "OFFLINE", label: t("exam.form.offline") },
{ value: "ONLINE", label: t("exam.form.online") },
]}
value={adminMethod}
onChange={setAdminMethod}
size="sm"
required
/>
<Select
label={t("exam.detail.evaluation")}
placeholder="How to compute score"
data={[
{ value: "SUM", label: t("exam.form.sum") },
{ value: "AVERAGE", label: t("exam.form.average") },
{ value: "PERCENTAGE", label: t("exam.form.percentage") },
]}
value={evalMethod}
onChange={setEvalMethod}
size="sm"
required
/>
<Select
label={t("exam.detail.selection")}
placeholder="Manual or Random"
data={[
{ value: "MANUAL", label: t("exam.form.manual") },
{ value: "RANDOM", label: t("exam.form.random") },
]}
value={selMethod}
onChange={setSelMethod}
size="sm"
/>
<NumberInput
label={t("exam.form.cuttingPoint")}
placeholder={t("exam.form.cuttingPointPlaceholder")}
value={cuttingPoint}
onChange={(v) => setCuttingPoint(Number(v))}
min={0}
size="sm"
required
/>
</SimpleGrid>
{editing && (
<Select
label={t("exam.form.status")}
placeholder={t("exam.form.statusPlaceholder")}
data={[
{ value: "PENDING", label: t("exam.form.pending") },
{ value: "ACTIVE", label: t("exam.form.active") },
{ value: "COMPLETED", label: t("exam.form.completed") },
{ value: "CANCELLED", label: t("exam.form.cancelled") },
{ value: "POSTPONED", label: t("exam.form.postponed") },
{ value: "PUBLISHED", label: t("exam.form.published") },
]}
value={status}
onChange={setStatus}
size="sm"
/>
)}
</Stack>
</Tabs.Panel>
</Tabs>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={onCancel} size="sm">{t('exam.cancel')}</Button>
<Button type="submit" size="sm" loading={isSubmitting}>{editing ? t('exam.update') : t('exam.create')}</Button>
</Group>
<ModalFooter mt="md">
<Button variant="default" onClick={onCancel} size="sm">
{t("exam.cancel")}
</Button>
<Button type="submit" size="sm" loading={isSubmitting}>
{editing ? t("exam.update") : t("exam.create")}
</Button>
</ModalFooter>
</form>
</Paper>
</Modal>
);
}
export function ExamPage() {
const navigate = useNavigate();
const { t, i18n } = useTranslation();
const locale = i18n.language as 'en' | 'am';
const { handleError } = useErrorHandler();
const locale = i18n.language as "en" | "am";
const { data: certRes } = useGetCertificationsQuery();
const { data, isLoading, isError, refetch } = useGetExamsQuery();
const { data, isFetching, isError, refetch } = useGetExamsQuery();
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable();
const [createExam, { isLoading: isCreating }] = useCreateExamMutation();
const [updateExam, { isLoading: isUpdating }] = useUpdateExamMutation();
const [deleteExam] = useDeleteExamMutation();
@@ -152,26 +350,40 @@ export function ExamPage() {
const [editing, setEditing] = useState<Exam | null>(null);
const [showForm, setShowForm] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Exam | null>(null);
const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false);
const [deleteOpened, { open: openDelete, close: closeDelete }] =
useDisclosure(false);
const certOptions = certifications.filter((c) => c.isActive).map((c) => ({ value: c.id, label: c.name[locale] }));
const getCertName = (id: string) => certifications.find((c) => c.id === id)?.name?.[locale] ?? '-';
const certOptions = certifications
.filter((c) => c.isActive)
.map((c) => ({ value: c.id, label: c.name[locale] }));
const getCertName = (id: string) =>
certifications.find((c) => c.id === id)?.name?.[locale] ?? "-";
const resetForm = () => { setEditing(null); setShowForm(false); };
const resetForm = () => {
setEditing(null);
setShowForm(false);
};
const handleSubmit = async (values: any, isEdit: boolean) => {
const payload: any = {
certificationId: values.certificationId,
title: { en: values.titleEn, am: values.titleAm },
direction: values.directionEn || values.directionAm ? { en: values.directionEn, am: values.directionAm } : undefined,
direction:
values.directionEn || values.directionAm
? { en: values.directionEn, am: values.directionAm }
: undefined,
date: values.date,
givenTime: { days: values.days, hours: values.hours, minutes: values.minutes },
givenTime: {
days: values.days,
hours: values.hours,
minutes: values.minutes,
},
type: values.type,
form: values.form,
venue: values.venue,
administrationMethod: values.adminMethod,
evaluationMethod: values.evalMethod,
selectionMethod: values.selMethod || 'MANUAL',
selectionMethod: values.selMethod || "MANUAL",
cuttingPoint: values.cuttingPoint,
};
if (isEdit) payload.status = values.status;
@@ -179,14 +391,14 @@ export function ExamPage() {
try {
if (isEdit && editing) {
await updateExam({ id: editing.id, ...payload }).unwrap();
notify.success(t('exam.updated'));
notify.success(t("exam.updated"));
} else {
await createExam(payload).unwrap();
notify.success(t('exam.created'));
notify.success(t("exam.created"));
}
resetForm();
} catch {
notify.error(t('exam.error'));
} catch (e) {
handleError(e);
}
};
@@ -194,27 +406,57 @@ export function ExamPage() {
if (!deleteTarget) return;
try {
await deleteExam(deleteTarget.id).unwrap();
notify.success(t('exam.deleted'));
notify.success(t("exam.deleted"));
closeDelete();
setDeleteTarget(null);
} catch {
notify.error(t('exam.error'));
} catch (e) {
handleError(e);
}
};
if (isLoading) return <Center py="xl"><Loader /></Center>;
if (isError) return <Alert icon={<IconInfoCircle size={16} />} color="red" title={t('exam.loadError')} />;
if (isError)
return (
<Alert
icon={<IconInfoCircle size={16} />}
color="red"
title={t("exam.loadError")}
/>
);
const columns = [
...examColumns(t, locale, getCertName, (exam) => navigate(`/exams/${exam.id}`)),
examActionsColumn(t, {
onEdit: (exam) => {
setEditing(exam);
setShowForm(true);
},
onDelete: (exam) => {
setDeleteTarget(exam);
openDelete();
},
onDetails: (exam) => navigate(`/exams/${exam.id}`),
}),
];
const page = paginate(exams);
return (
<Stack gap="lg">
<Group justify="space-between" align="flex-end">
<div>
<Title order={2}>{t('exam.title')}</Title>
<Text fz="sm" c="dimmed">{t('exam.subtitle')}</Text>
<Title order={2}>{t("exam.title")}</Title>
<Text fz="sm" c="dimmed">
{t("exam.subtitle")}
</Text>
</div>
{!showForm && (
<Button variant="light" leftSection={<IconPlus size={16} />} onClick={() => setShowForm(true)} size="sm">
{t('exam.add')}
<Button
variant="light"
leftSection={<IconPlus size={16} />}
onClick={() => setShowForm(true)}
size="sm"
>
{t("exam.add")}
</Button>
)}
</Group>
@@ -229,31 +471,42 @@ export function ExamPage() {
/>
)}
<Paper withBorder radius="md">
<Card withBorder padding={0}>
<AdvancedTable
columns={examColumns(t, {
locale,
getCertName,
onTitleClick: (exam) => navigate(`/exams/${exam.id}`),
})}
data={exams}
rowKey={(exam) => exam.id}
actions={examColumnActions(t, {
onEdit: (exam) => { setEditing(exam); setShowForm(true); },
onDelete: (exam) => { setDeleteTarget(exam); openDelete(); },
})}
onRefresh={refetch}
emptyTitle={t('exam.noItems')}
columns={columns}
data={page.rows}
tableName={t("exam.title")}
itemCount={page.itemCount}
pageIndex={page.pageIndex}
onPageChange={setPageIndex}
pageSize={pageSize}
onPageSizeChange={setPageSize}
refresh={refetch}
isLoading={isFetching}
emptyText={t("exam.noItems")}
/>
</Paper>
</Card>
{/* Delete confirmation */}
<Modal opened={deleteOpened} onClose={closeDelete} title={t('exam.confirmDelete')} size="sm">
<Text mb="md">{t('exam.deleteConfirmText', { name: deleteTarget?.title?.[locale] ?? '' })}</Text>
<Group justify="flex-end">
<Button variant="default" onClick={closeDelete} size="sm">{t('exam.cancel')}</Button>
<Button color="red" onClick={handleDelete} size="sm">{t('exam.delete')}</Button>
</Group>
<Modal
opened={deleteOpened}
onClose={closeDelete}
title={t("exam.confirmDelete")}
size="sm"
>
<Text mb="md">
{t("exam.deleteConfirmText", {
name: deleteTarget?.title?.[locale] ?? "",
})}
</Text>
<ModalFooter>
<Button variant="default" onClick={closeDelete} size="sm">
{t("exam.cancel")}
</Button>
<Button color="red" onClick={handleDelete} size="sm">
{t("exam.delete")}
</Button>
</ModalFooter>
</Modal>
</Stack>
);

View File

@@ -1,13 +1,19 @@
import type { LocalePair } from '../../certification/types/certification';
import type { EstimatedTime } from '../../question/types/question';
import type { QuestionForm } from '../../question/types/question';
import type { LocalePair } from "../../certification/types/certification";
import type { EstimatedTime } from "../../question/types/question";
import type { QuestionForm } from "../../question/types/question";
export type { QuestionForm };
export type ExamType = 'WRITTEN' | 'ORAL';
export type ExamAdministrationMethod = 'OFFLINE' | 'ONLINE';
export type ExamEvaluationMethod = 'SUM' | 'AVERAGE' | 'PERCENTAGE';
export type ExamSelectionMethod = 'MANUAL' | 'RANDOM';
export type ExamStatus = 'PENDING' | 'ACTIVE' | 'COMPLETED' | 'CANCELLED' | 'POSTPONED' | 'PUBLISHED';
export type ExamType = "WRITTEN" | "ORAL";
export type ExamAdministrationMethod = "OFFLINE" | "ONLINE";
export type ExamEvaluationMethod = "SUM" | "AVERAGE" | "PERCENTAGE";
export type ExamSelectionMethod = "MANUAL" | "RANDOM";
export type ExamStatus =
| "PENDING"
| "ACTIVE"
| "COMPLETED"
| "CANCELLED"
| "POSTPONED"
| "PUBLISHED";
export interface QuestionBrief {
id: string;