mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
test
This commit is contained in:
@@ -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 }),
|
||||
|
||||
@@ -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 }] =
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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: () => ({}),
|
||||
});
|
||||
|
||||
1
libs/api/src/lib/base-api/tagTypes.ts
Normal file
1
libs/api/src/lib/base-api/tagTypes.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const tagTypes = ["ProfessionApi"];
|
||||
Reference in New Issue
Block a user