This commit is contained in:
Estifo77
2026-07-27 11:52:56 +03:00
parent af85fca02a
commit 163e02e7af
9 changed files with 834 additions and 277 deletions

View File

@@ -14,12 +14,11 @@ const configurationApi = baseApi.injectEndpoints({
providesTags: ["Api"],
}),
getProfessions: builder.query<ListResponse<Profession>, { q?: string }>({
getProfessions: builder.query<ListResponse<Profession>, string>({
query: (params) => ({
url: "/professions",
params,
url: `/professions?q=${encodeURIComponent(params)}`,
}),
providesTags: ["Api"],
providesTags: ["Api", "backOfficeApi", "ProfessionApi"],
}),
createProfession: builder.mutation<Profession, CreateProfessionPayload>({
query: (body) => ({ url: "/professions", method: "POST", body }),

View File

@@ -172,9 +172,9 @@ function ProfessionTab() {
isFetching,
isError,
refetch,
} = useGetProfessionsQuery({
q: `skip:${skip},take:${take},orderBy:createdAt:DESC`,
});
} = useGetProfessionsQuery(
`skip:${skip},take:${take},orderBy:createdAt:DESC`,
);
const [createProfession, { isLoading: isCreating }] =
useCreateProfessionMutation();
const [updateProfession, { isLoading: isUpdating }] =

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { Dispatch, SetStateAction, useState } from "react";
import {
Paper,
Group,
@@ -10,16 +10,17 @@ 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, actionTypes } from "../types/exam";
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 +39,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 +60,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 +71,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 +114,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 +186,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,
@@ -38,68 +38,116 @@ import {
IconUser,
IconCheck,
IconX,
} from '@tabler/icons-react';
import { notify, useErrorHandler } from '@ema-platform/ui';
import { useGetExamQuery, useUpdateExamMutation, useAssignQuestionsMutation } from '../api/exam-api';
import { useGetQuestionsQuery } from '../../question/api/question-api';
import { useGetCertificationsQuery } from '../../certification/api/certification-api';
import { QuestionAssigner } from '../components/QuestionAssigner';
import { RecordResultModal } from '../../result/components/RecordResultModal';
import type { ExamStatus, QuestionBrief } from '../types/exam';
IconDetails
} from "@tabler/icons-react";
import { notify, useErrorHandler } from "@ema-platform/ui";
import {
useGetExamQuery,
useUpdateExamMutation,
useAssignQuestionsMutation,
} from "../api/exam-api";
import { useGetQuestionsQuery } from "../../question/api/question-api";
import { useGetCertificationsQuery } from "../../certification/api/certification-api";
import { QuestionAssigner } from "../components/QuestionAssigner";
import { RecordResultModal } from "../../result/components/RecordResultModal";
import type { ExamStatus, QuestionBrief, actionTypes } 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();
const [assignQuestions, { isLoading: isAssigning }] = useAssignQuestionsMutation();
const { data: exam, isLoading, isError } = useGetExamQuery(id ?? '', { skip: !id });
const [assignQuestions, { isLoading: isAssigning }] =
useAssignQuestionsMutation();
const {
data: exam,
isLoading,
isError,
} = useGetExamQuery(id ?? "", { skip: !id });
const { data: qRes } = useGetQuestionsQuery();
const { data: certRes } = useGetCertificationsQuery();
const allQuestions = qRes?.items ?? [];
const certifications = certRes?.items ?? [];
const [whatAction, setWhatAction] = useState<actionTypes>();
const eligibleQuestions = useMemo(() => {
if (!exam) return [];
return allQuestions
.filter((q) => q.certificationId === exam.certificationId && q.form === exam.form)
.map((q) => ({ id: q.id, title: q.title, form: q.form, points: q.points }));
.filter(
(q) =>
q.certificationId === exam.certificationId && q.form === exam.form,
)
.map((q) => ({
id: q.id,
title: q.title,
form: q.form,
points: q.points,
}));
// 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>
);
}
@@ -112,18 +160,24 @@ export function ExamDetailPage() {
const handleRandomSelect = () => {
const assignedIds = new Set(draftQuestions.map((q) => q.id));
const currentTotal = draftQuestions.reduce((s, q) => s + Number(q.points), 0);
const currentTotal = draftQuestions.reduce(
(s, q) => s + Number(q.points),
0,
);
const cuttingPoint = Number(exam.cuttingPoint);
const eligible = eligibleQuestions.filter((q) => !assignedIds.has(q.id));
if (eligible.length === 0) {
notify.error('No eligible questions available for random selection');
notify.error("No eligible questions available for random selection");
return;
}
const maxPossible = currentTotal + eligible.reduce((s, q) => s + Number(q.points), 0);
const maxPossible =
currentTotal + eligible.reduce((s, q) => s + Number(q.points), 0);
if (maxPossible < cuttingPoint) {
notify.error(`Even all eligible questions combined (${maxPossible} pts) cannot reach the passing mark (${cuttingPoint} pts). Adjust the cutting point or add more questions.`);
notify.error(
`Even all eligible questions combined (${maxPossible} pts) cannot reach the passing mark (${cuttingPoint} pts). Adjust the cutting point or add more questions.`,
);
return;
}
@@ -141,9 +195,10 @@ export function ExamDetailPage() {
}
}
const msg = picked.length > targetCount
? `Selected ${picked.length} questions (${picked.length - targetCount} extra added to meet the ${cuttingPoint} pts passing mark)`
: `Randomly selected ${picked.length} questions`;
const msg =
picked.length > targetCount
? `Selected ${picked.length} questions (${picked.length - targetCount} extra added to meet the ${cuttingPoint} pts passing mark)`
: `Randomly selected ${picked.length} questions`;
setDraftQuestions([...draftQuestions, ...picked]);
notify.info(msg);
@@ -152,49 +207,73 @@ export function ExamDetailPage() {
const handleAssign = async () => {
try {
const questionIds = draftQuestions.map((q) => q.id);
await assignQuestions({ examId: exam.id, questionIds, remark: undefined }).unwrap();
notify.success('Questions assigned');
closeAssign();
if (whatAction === "add") {
await assignQuestions({
examId: exam.id,
questionIds,
remark: undefined,
}).unwrap();
notify.success("Questions assigned");
closeAssign();
} else if (whatAction === "remove") {
// await removeQuestions({
// examId: exam.id,
// questionIds,
// remark: undefined,
// });
// notify.success("Questions removed");
// closeAssign();
notify.warning("no action yet");
}
} catch (e) {
handleError(e);
}
};
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>
@@ -209,13 +288,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
@@ -227,15 +306,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>
@@ -243,41 +332,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>
@@ -285,24 +426,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>
@@ -312,32 +470,50 @@ export function ExamDetailPage() {
)}
</Paper>
<RecordResultModal exam={exam} opened={recordOpened} onClose={closeRecord} />
<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}
assigned={draftQuestions}
onChange={setDraftQuestions}
mode="manual"
actions={setWhatAction}
/>
<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>
<Button variant="default" onClick={closeAssign} size="sm">
{t("exam.cancel")}
</Button>
<Button onClick={handleAssign} size="sm" loading={isAssigning}>
{t("exam.saveAssignments")}
</Button>
</Group>
</>
) : (
<>
<Text fz="sm" c="dimmed">
{t('exam.assigner.randomHint', { total: eligibleQuestions.length, pts: exam.cuttingPoint })}
{t("exam.assigner.randomHint", {
total: eligibleQuestions.length,
pts: exam.cuttingPoint,
})}
</Text>
<Group gap="sm">
<NumberInput
placeholder={t('exam.assigner.selectCount')}
placeholder={t("exam.assigner.selectCount")}
value={randomCount}
onChange={(v) => setRandomCount(Number(v))}
min={1}
@@ -346,7 +522,7 @@ export function ExamDetailPage() {
style={{ width: 80 }}
/>
<Button size="xs" variant="light" onClick={handleRandomSelect}>
{t('exam.randomSelect')}
{t("exam.randomSelect")}
</Button>
</Group>
<QuestionAssigner
@@ -356,8 +532,12 @@ export function ExamDetailPage() {
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>
<Button variant="default" onClick={closeAssign} size="sm">
{t("exam.cancel")}
</Button>
<Button onClick={handleAssign} size="sm" loading={isAssigning}>
{t("exam.saveAssignments")}
</Button>
</Group>
</>
)}

View File

@@ -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,
@@ -21,27 +21,35 @@ import {
Tabs,
SimpleGrid,
Divider,
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { useTranslation } from 'react-i18next';
import { IconEdit, IconTrash, IconPlus, IconInfoCircle, IconCalendar, IconClipboardList } from '@tabler/icons-react';
import { notify, useErrorHandler } 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 {
IconEdit,
IconTrash,
IconPlus,
IconInfoCircle,
IconCalendar,
IconClipboardList,
IconDetails,
} from "@tabler/icons-react";
import { notify, useErrorHandler } 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';
} from "../api/exam-api";
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",
};
function ExamForm({
@@ -58,89 +66,285 @@ 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">
<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}
/>
<TextInput
label={t("exam.form.examDate")}
type="date"
value={date}
onChange={(e) => setDate(e.currentTarget.value)}
size="sm"
leftSection={<IconCalendar size={14} />}
required
/>
<TextInput
label={t("exam.form.venue")}
placeholder={t("exam.form.venuePlaceholder")}
value={venue}
onChange={(e) => setVenue(e.currentTarget.value)}
size="sm"
required
/>
<Text fz="sm" fw={500}>{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>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={onCancel} size="sm">
{t("exam.cancel")}
</Button>
<Button type="submit" size="sm" loading={isSubmitting}>
{editing ? t("exam.update") : t("exam.create")}
</Button>
</Group>
</form>
</Paper>
);
@@ -150,7 +354,7 @@ export function ExamPage() {
const navigate = useNavigate();
const { t, i18n } = useTranslation();
const { handleError } = useErrorHandler();
const locale = i18n.language as 'en' | 'am';
const locale = i18n.language as "en" | "am";
const { data: certRes } = useGetCertificationsQuery();
const { data, isLoading, isError } = useGetExamsQuery();
const [createExam, { isLoading: isCreating }] = useCreateExamMutation();
@@ -163,26 +367,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;
@@ -190,10 +408,10 @@ 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 (e) {
@@ -205,7 +423,7 @@ 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 (e) {
@@ -213,19 +431,38 @@ export function ExamPage() {
}
};
if (isLoading) return <Center py="xl"><Loader /></Center>;
if (isError) return <Alert icon={<IconInfoCircle size={16} />} color="red" title={t('exam.loadError')} />;
if (isLoading)
return (
<Center py="xl">
<Loader />
</Center>
);
if (isError)
return (
<Alert
icon={<IconInfoCircle size={16} />}
color="red"
title={t("exam.loadError")}
/>
);
return (
<Stack gap="lg">
<Group justify="space-between" align="flex-end">
<div>
<Title order={2}>{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>
@@ -244,14 +481,15 @@ export function ExamPage() {
<Table striped highlightOnHover>
<Table.Thead bg="var(--mantine-color-default-hover)">
<Table.Tr>
<Table.Th>{t('exam.columns.title')}</Table.Th>
<Table.Th>{t('exam.columns.certification')}</Table.Th>
<Table.Th>{t('exam.columns.date')}</Table.Th>
<Table.Th>{t('exam.columns.type')}</Table.Th>
<Table.Th>{t('exam.columns.form')}</Table.Th>
<Table.Th>{t('exam.columns.venue')}</Table.Th>
<Table.Th>{t('exam.columns.questions')}</Table.Th>
<Table.Th>{t('exam.columns.status')}</Table.Th>
<Table.Th>{t("exam.columns.title")}</Table.Th>
<Table.Th>{t("exam.columns.certification")}</Table.Th>
<Table.Th>{t("exam.columns.date")}</Table.Th>
<Table.Th>{t("exam.columns.type")}</Table.Th>
<Table.Th>{t("exam.columns.form")}</Table.Th>
<Table.Th>{t("exam.columns.venue")}</Table.Th>
<Table.Th>{t("exam.columns.questions")}</Table.Th>
<Table.Th>{t("exam.columns.status")}</Table.Th>
<Table.Th>{t("exam.columns.actions")}</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
@@ -259,29 +497,91 @@ export function ExamPage() {
{exams.map((exam) => (
<Table.Tr key={exam.id}>
<Table.Td>
<Text fz="sm" fw={500} c="blue" style={{ cursor: 'pointer' }} onClick={() => navigate(`/exams/${exam.id}`)}>
<Text
fz="sm"
fw={500}
c="blue"
style={{ cursor: "pointer" }}
onClick={() => navigate(`/exams/${exam.id}`)}
>
{exam.title[locale]}
</Text>
</Table.Td>
<Table.Td><Text fz="sm">{getCertName(exam.certificationId)}</Text></Table.Td>
<Table.Td><Text fz="sm">{exam.date}</Text></Table.Td>
<Table.Td><Badge size="sm" variant="light" color={exam.type === 'WRITTEN' ? 'blue' : 'orange'}>{t(`exam.type.${exam.type}`)}</Badge></Table.Td>
<Table.Td><Badge size="sm" variant="light" color={exam.form === 'ESSAY' ? 'blue' : 'violet'}>{t(`exam.formType.${exam.form}`)}</Badge></Table.Td>
<Table.Td><Text fz="sm">{exam.venue}</Text></Table.Td>
<Table.Td>
<Badge size="sm" variant="light" color="gray">{exam.questions?.length ?? 0}</Badge>
<Text fz="sm">{getCertName(exam.certificationId)}</Text>
</Table.Td>
<Table.Td>
<Badge size="sm" variant="light" color={STATUS_COLOR[exam.status]}>{t(`exam.status.${exam.status}`)}</Badge>
<Text fz="sm">{exam.date}</Text>
</Table.Td>
<Table.Td>
<Badge
size="sm"
variant="light"
color={exam.type === "WRITTEN" ? "blue" : "orange"}
>
{t(`exam.type.${exam.type}`)}
</Badge>
</Table.Td>
<Table.Td>
<Badge
size="sm"
variant="light"
color={exam.form === "ESSAY" ? "blue" : "violet"}
>
{t(`exam.formType.${exam.form}`)}
</Badge>
</Table.Td>
<Table.Td>
<Text fz="sm">{exam.venue}</Text>
</Table.Td>
<Table.Td>
<Badge size="sm" variant="light" color="gray">
{exam.questions?.length ?? 0}
</Badge>
</Table.Td>
<Table.Td>
<Badge
size="sm"
variant="light"
color={STATUS_COLOR[exam.status]}
>
{t(`exam.status.${exam.status}`)}
</Badge>
</Table.Td>
<Table.Td>
<Group gap="xs">
<ActionIcon variant="subtle" color="blue" size="sm" onClick={() => { setEditing(exam); setShowForm(true); }}>
<ActionIcon
variant="subtle"
color="blue"
size="sm"
onClick={() => {
setEditing(exam);
setShowForm(true);
}}
>
<IconEdit size={14} />
</ActionIcon>
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => { setDeleteTarget(exam); openDelete(); }}>
<ActionIcon
variant="subtle"
color="red"
size="sm"
onClick={() => {
setDeleteTarget(exam);
openDelete();
}}
>
<IconTrash size={14} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="red"
size="sm"
onClick={() => {
navigate(`/exams/${exam.id}`);
}}
>
<IconDetails size={14} />
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
@@ -289,7 +589,9 @@ export function ExamPage() {
{exams.length === 0 && (
<Table.Tr>
<Table.Td colSpan={9}>
<Text c="dimmed" ta="center" py="xl">{t('exam.noItems')}</Text>
<Text c="dimmed" ta="center" py="xl">
{t("exam.noItems")}
</Text>
</Table.Td>
</Table.Tr>
)}
@@ -298,11 +600,24 @@ export function ExamPage() {
</Paper>
{/* Delete confirmation */}
<Modal opened={deleteOpened} onClose={closeDelete} title={t('exam.confirmDelete')} size="sm">
<Text mb="md">{t('exam.deleteConfirmText', { name: deleteTarget?.title?.[locale] ?? '' })}</Text>
<Modal
opened={deleteOpened}
onClose={closeDelete}
title={t("exam.confirmDelete")}
size="sm"
>
<Text mb="md">
{t("exam.deleteConfirmText", {
name: deleteTarget?.title?.[locale] ?? "",
})}
</Text>
<Group justify="flex-end">
<Button variant="default" onClick={closeDelete} size="sm">{t('exam.cancel')}</Button>
<Button color="red" onClick={handleDelete} size="sm">{t('exam.delete')}</Button>
<Button variant="default" onClick={closeDelete} size="sm">
{t("exam.cancel")}
</Button>
<Button color="red" onClick={handleDelete} size="sm">
{t("exam.delete")}
</Button>
</Group>
</Modal>
</Stack>

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;
@@ -79,3 +85,4 @@ export interface AssignQuestionsPayload {
questionIds: string[];
remark?: LocalePair;
}
export type actionTypes = "add" | "remove";

View File

@@ -126,7 +126,6 @@ export function ProfilePage() {
professions.forEach((p) => { map[p.id] = p.name.en; });
return map;
}, [professions]);
// ---- Profile data (from stored currentProfile) ----
const [fetchProfile] = useApiMutation<{ total: number; items: CurrentProfile[] }>();
const [updateProfile] = useApiMutation<unknown>();

View File

@@ -1,9 +1,9 @@
import { createApi } from '@reduxjs/toolkit/query/react';
import { baseQueryWithReauth } from './base-query-with-reauth';
import { createApi } from "@reduxjs/toolkit/query/react";
import { baseQueryWithReauth } from "./base-query-with-reauth";
import { tagTypes } from "./tagTypes";
export const baseApi = createApi({
reducerPath: 'baseApi',
reducerPath: "baseApi",
baseQuery: baseQueryWithReauth,
tagTypes: ['Api'],
tagTypes: ["Api", "backOfficeApi", "portalApi", ...tagTypes],
endpoints: () => ({}),
});

View File

@@ -0,0 +1 @@
export const tagTypes = ["ProfessionApi"];