mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 13:02:50 +00:00
615 lines
20 KiB
TypeScript
615 lines
20 KiB
TypeScript
import { useState } from "react";
|
|
import { useNavigate } from "react-router-dom";
|
|
import {
|
|
Stack,
|
|
Group,
|
|
Button,
|
|
Modal,
|
|
Text,
|
|
TextInput,
|
|
Textarea,
|
|
Card,
|
|
Select,
|
|
NumberInput,
|
|
Tabs,
|
|
SimpleGrid,
|
|
} from "@mantine/core";
|
|
import { useDisclosure } from "@mantine/hooks";
|
|
import { useTranslation } from "react-i18next";
|
|
import {
|
|
IconPlus,
|
|
IconInfoCircle,
|
|
IconClipboardList,
|
|
} from "@tabler/icons-react";
|
|
import { notify, useErrorHandler, AdvancedTable, useServerTable, ModalFooter, AmharicDatePicker } from "@ema-platform/ui";
|
|
import { LICENSE_PERMISSIONS, RequirePermission } from "@ema-platform/auth";
|
|
import { useGetCertificationsQuery } from "../../../certification/api/certification-api";
|
|
import { useGetRanksQuery, useLocalized } from "@ema-platform/api";
|
|
import {
|
|
useGetExamsQuery,
|
|
useCreateExamMutation,
|
|
useUpdateExamMutation,
|
|
useDeleteExamMutation,
|
|
} from "../../api/exam-api";
|
|
import type { Exam } from "../../types/exam";
|
|
import { examColumns } from "./columns";
|
|
import { examActionsColumn } from "./actions";
|
|
import { ErrorState, PageHeader } from '@ema-platform/ui';
|
|
|
|
function ExamForm({
|
|
editing,
|
|
certOptions,
|
|
isSubmitting,
|
|
onSubmit,
|
|
onCancel,
|
|
}: {
|
|
editing: Exam | null;
|
|
certOptions: { value: string; label: string }[];
|
|
isSubmitting: boolean;
|
|
onSubmit: (values: any, isEdit: boolean) => void;
|
|
onCancel: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const [certificationId, setCertificationId] = useState<string | null>(
|
|
editing?.certificationId ?? null,
|
|
);
|
|
const [titleEn, setTitleEn] = useState(editing?.title?.en ?? "");
|
|
const [titleAm, setTitleAm] = useState(editing?.title?.am ?? "");
|
|
const [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 [status, setStatus] = useState<string | null>(editing?.status ?? null);
|
|
const [activeTab, setActiveTab] = useState<string | null>("basic");
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!certificationId || !titleEn || !titleAm || !date || !venue) {
|
|
setActiveTab("basic");
|
|
notify.error(t("exam.form.fillRequiredBasic"));
|
|
return;
|
|
}
|
|
if ((directionEn || directionAm) && !(directionEn && directionAm)) {
|
|
setActiveTab("basic");
|
|
notify.error(t("exam.form.directionBothLanguages"));
|
|
return;
|
|
}
|
|
if (!type || !form || !adminMethod || !evalMethod || !cuttingPoint) {
|
|
setActiveTab("settings");
|
|
notify.error(t("exam.form.fillRequiredSettings"));
|
|
return;
|
|
}
|
|
onSubmit(
|
|
{
|
|
certificationId,
|
|
titleEn,
|
|
titleAm,
|
|
directionEn,
|
|
directionAm,
|
|
date,
|
|
days,
|
|
hours,
|
|
minutes,
|
|
type,
|
|
form,
|
|
venue,
|
|
adminMethod,
|
|
evalMethod,
|
|
selMethod,
|
|
cuttingPoint,
|
|
status,
|
|
},
|
|
!!editing,
|
|
);
|
|
};
|
|
|
|
return (
|
|
<Modal opened onClose={onCancel} title={editing ? t("exam.update") : t("exam.add")} size="xl">
|
|
<form onSubmit={handleSubmit}>
|
|
<Tabs value={activeTab} onChange={setActiveTab} 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}
|
|
/>
|
|
<AmharicDatePicker
|
|
label={t("exam.form.examDate")}
|
|
value={date}
|
|
onChange={setDate}
|
|
dateFormat="date"
|
|
size="sm"
|
|
required
|
|
/>
|
|
<TextInput
|
|
label={t("exam.form.venue")}
|
|
placeholder={t("exam.form.venuePlaceholder")}
|
|
value={venue}
|
|
onChange={(e) => setVenue(e.currentTarget.value)}
|
|
size="sm"
|
|
required
|
|
/>
|
|
|
|
<Text fz="sm" fw={500}>
|
|
{t("exam.form.timeAllowed")}
|
|
</Text>
|
|
<Group gap="sm" grow>
|
|
<NumberInput
|
|
label={t("exam.form.days")}
|
|
value={days}
|
|
onChange={(v) => setDays(Number(v))}
|
|
min={0}
|
|
size="sm"
|
|
/>
|
|
<NumberInput
|
|
label={t("exam.form.hours")}
|
|
value={hours}
|
|
onChange={(v) => setHours(Number(v))}
|
|
min={0}
|
|
size="sm"
|
|
/>
|
|
<NumberInput
|
|
label={t("exam.form.minutes")}
|
|
value={minutes}
|
|
onChange={(v) => setMinutes(Number(v))}
|
|
min={0}
|
|
size="sm"
|
|
/>
|
|
</Group>
|
|
</Stack>
|
|
</Tabs.Panel>
|
|
|
|
<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: "BOTH", label: t("exam.form.both") },
|
|
]}
|
|
value={form}
|
|
onChange={setForm}
|
|
size="sm"
|
|
required
|
|
disabled={adminMethod === "ONLINE"}
|
|
description={
|
|
adminMethod === "ONLINE"
|
|
? t("exam.form.onlineChoiceOnlyHint")
|
|
: undefined
|
|
}
|
|
/>
|
|
<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={(value) => {
|
|
setAdminMethod(value);
|
|
// Online exams are graded automatically, and that only
|
|
// has an answer model for CHOICE — matches the backend
|
|
// rule (online_exam_requires_choice_form), not just a
|
|
// UI nicety.
|
|
if (value === "ONLINE") setForm("CHOICE");
|
|
}}
|
|
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={
|
|
evalMethod === "PERCENTAGE"
|
|
? t("exam.form.cuttingPointPercentagePlaceholder")
|
|
: t("exam.form.cuttingPointPlaceholder")
|
|
}
|
|
value={cuttingPoint}
|
|
onChange={(v) => setCuttingPoint(Number(v))}
|
|
min={0}
|
|
max={evalMethod === "PERCENTAGE" ? 100 : undefined}
|
|
size="sm"
|
|
withAsterisk
|
|
description={
|
|
evalMethod === "PERCENTAGE"
|
|
? t("exam.form.cuttingPointPercentageHint")
|
|
: undefined
|
|
}
|
|
/>
|
|
</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>
|
|
|
|
<ModalFooter mt="md">
|
|
<Button variant="default" onClick={onCancel} size="sm">
|
|
{t("exam.cancel")}
|
|
</Button>
|
|
<Button type="submit" size="sm" loading={isSubmitting}>
|
|
{editing ? t("exam.update") : t("exam.create")}
|
|
</Button>
|
|
</ModalFooter>
|
|
</form>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
export function ExamPage() {
|
|
const navigate = useNavigate();
|
|
const { t, i18n } = useTranslation();
|
|
const { handleError } = useErrorHandler();
|
|
const locale = i18n.language as "en" | "am";
|
|
const localized = useLocalized();
|
|
const { data: certRes } = useGetCertificationsQuery();
|
|
const { data: rankRes } = useGetRanksQuery();
|
|
const rankLabelByKey = new Map((rankRes?.items ?? []).map((r) => [r.key, localized(r.name)]));
|
|
const { data, isFetching, isError, refetch } = useGetExamsQuery();
|
|
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable();
|
|
const [createExam, { isLoading: isCreating }] = useCreateExamMutation();
|
|
const [updateExam, { isLoading: isUpdating }] = useUpdateExamMutation();
|
|
const [deleteExam] = useDeleteExamMutation();
|
|
|
|
const certifications = certRes?.items ?? [];
|
|
const exams = data?.items ?? [];
|
|
|
|
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 [changingStatusId, setChangingStatusId] = useState<string | null>(null);
|
|
const [statusTarget, setStatusTarget] = useState<Exam | null>(null);
|
|
const [pendingStatus, setPendingStatus] = useState<Exam["status"] | null>(null);
|
|
const [statusOpened, { open: openStatus, close: closeStatus }] =
|
|
useDisclosure(false);
|
|
|
|
// Rank in the label: an exam inherits its STCW rank from the certification
|
|
// it is created under (Certification.rankKey), so the officer sees which
|
|
// rank a sitting will serve at the moment they pick the subject.
|
|
const certOptions = certifications
|
|
.filter((c) => c.isActive)
|
|
.map((c) => {
|
|
const rank = c.rankKey ? rankLabelByKey.get(c.rankKey) : undefined;
|
|
return {
|
|
value: c.id,
|
|
label: rank ? `${c.name[locale]} — ${rank}` : c.name[locale],
|
|
};
|
|
});
|
|
const getCertName = (id: string) =>
|
|
certifications.find((c) => c.id === id)?.name?.[locale] ?? "-";
|
|
|
|
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,
|
|
date: values.date,
|
|
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",
|
|
cuttingPoint: values.cuttingPoint,
|
|
};
|
|
if (isEdit) payload.status = values.status;
|
|
|
|
try {
|
|
if (isEdit && editing) {
|
|
await updateExam({ id: editing.id, ...payload }).unwrap();
|
|
notify.success(t("exam.updated"));
|
|
} else {
|
|
await createExam(payload).unwrap();
|
|
notify.success(t("exam.created"));
|
|
}
|
|
resetForm();
|
|
} catch (e) {
|
|
handleError(e);
|
|
}
|
|
};
|
|
|
|
const handleChangeStatus = async () => {
|
|
if (!statusTarget || !pendingStatus) return;
|
|
setChangingStatusId(statusTarget.id);
|
|
try {
|
|
await updateExam({ id: statusTarget.id, status: pendingStatus }).unwrap();
|
|
notify.success(t("exam.updated"));
|
|
closeStatus();
|
|
setStatusTarget(null);
|
|
} catch (e) {
|
|
handleError(e);
|
|
} finally {
|
|
setChangingStatusId(null);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async () => {
|
|
if (!deleteTarget) return;
|
|
try {
|
|
await deleteExam(deleteTarget.id).unwrap();
|
|
notify.success(t("exam.deleted"));
|
|
closeDelete();
|
|
setDeleteTarget(null);
|
|
} catch (e) {
|
|
handleError(e);
|
|
}
|
|
};
|
|
|
|
if (isError)
|
|
return <ErrorState title={t("exam.loadError")} onRetry={refetch} />;
|
|
|
|
const columns = [
|
|
...examColumns(t, locale, getCertName, (exam) => navigate(`/exams/${exam.id}`)),
|
|
examActionsColumn(t, {
|
|
onEdit: (exam) => {
|
|
setEditing(exam);
|
|
setShowForm(true);
|
|
},
|
|
onDelete: (exam) => {
|
|
setDeleteTarget(exam);
|
|
openDelete();
|
|
},
|
|
onDetails: (exam) => navigate(`/exams/${exam.id}`),
|
|
onOpenStatusChange: (exam) => {
|
|
setStatusTarget(exam);
|
|
setPendingStatus(exam.status);
|
|
openStatus();
|
|
},
|
|
changingStatusId,
|
|
}),
|
|
];
|
|
|
|
const page = paginate(exams);
|
|
|
|
return (
|
|
<Stack gap="lg">
|
|
<PageHeader
|
|
title={t("exam.title")}
|
|
subtitle={t("exam.subtitle")}
|
|
noMargin
|
|
action={
|
|
!showForm && (
|
|
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_EXAMS]} hideOnly>
|
|
<Button
|
|
variant="light"
|
|
leftSection={<IconPlus size={16} />}
|
|
onClick={() => setShowForm(true)}
|
|
size="sm"
|
|
>
|
|
{t("exam.add")}
|
|
</Button>
|
|
</RequirePermission>
|
|
)
|
|
}
|
|
/>
|
|
|
|
{showForm && (
|
|
<ExamForm
|
|
editing={editing}
|
|
certOptions={certOptions}
|
|
isSubmitting={isCreating || isUpdating}
|
|
onSubmit={handleSubmit}
|
|
onCancel={resetForm}
|
|
/>
|
|
)}
|
|
|
|
<Card withBorder padding={0}>
|
|
<AdvancedTable
|
|
columns={columns}
|
|
data={page.rows}
|
|
tableName={t("exam.title")}
|
|
itemCount={page.itemCount}
|
|
pageIndex={page.pageIndex}
|
|
onPageChange={setPageIndex}
|
|
pageSize={pageSize}
|
|
onPageSizeChange={setPageSize}
|
|
refresh={refetch}
|
|
isLoading={isFetching}
|
|
emptyText={t("exam.noItems")}
|
|
/>
|
|
</Card>
|
|
|
|
{/* Delete confirmation */}
|
|
<Modal
|
|
opened={deleteOpened}
|
|
onClose={closeDelete}
|
|
title={t("exam.confirmDelete")}
|
|
size="sm"
|
|
>
|
|
<Text mb="md">
|
|
{t("exam.deleteConfirmText", {
|
|
name: deleteTarget?.title?.[locale] ?? "",
|
|
})}
|
|
</Text>
|
|
<ModalFooter>
|
|
<Button variant="default" onClick={closeDelete} size="sm">
|
|
{t("exam.cancel")}
|
|
</Button>
|
|
<Button color="red" onClick={handleDelete} size="sm">
|
|
{t("exam.delete")}
|
|
</Button>
|
|
</ModalFooter>
|
|
</Modal>
|
|
|
|
{/* Quick status change — not the full edit form */}
|
|
<Modal
|
|
opened={statusOpened}
|
|
onClose={closeStatus}
|
|
title={t("exam.form.status")}
|
|
size="sm"
|
|
>
|
|
<Stack gap="md">
|
|
<Text fz="sm" c="dimmed">
|
|
{statusTarget?.title?.[locale]}
|
|
</Text>
|
|
<Select
|
|
label={t("exam.form.status")}
|
|
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={pendingStatus}
|
|
onChange={(value) => setPendingStatus(value as Exam["status"])}
|
|
size="sm"
|
|
/>
|
|
<ModalFooter>
|
|
<Button variant="default" onClick={closeStatus} size="sm">
|
|
{t("exam.cancel")}
|
|
</Button>
|
|
<Button
|
|
onClick={handleChangeStatus}
|
|
size="sm"
|
|
loading={changingStatusId === statusTarget?.id}
|
|
disabled={pendingStatus === statusTarget?.status}
|
|
>
|
|
{t("exam.update")}
|
|
</Button>
|
|
</ModalFooter>
|
|
</Stack>
|
|
</Modal>
|
|
</Stack>
|
|
);
|
|
}
|