Files
emaui/apps/backoffice/src/app/features/exam/pages/ExamPage/index.tsx

612 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 { RANK_KEY_OPTIONS } from "../../../certification/types/certification";
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 { data: certRes } = useGetCertificationsQuery();
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 = RANK_KEY_OPTIONS.find((r) => r.value === c.rankKey)?.label;
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>
);
}