mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-07 11:55:43 +00:00
Merge branch 'WorkflowChange' of github.com:Tria-plc/emaui into WorkflowChange
This commit is contained in:
@@ -11,6 +11,7 @@ import type {
|
||||
ExamIncident,
|
||||
CreateIncidentPayload,
|
||||
ResolveIncidentPayload,
|
||||
RegradeOutcome,
|
||||
} from '../types/exam';
|
||||
|
||||
const examApi = baseApi.injectEndpoints({
|
||||
@@ -20,7 +21,9 @@ const examApi = baseApi.injectEndpoints({
|
||||
providesTags: ['Api'],
|
||||
}),
|
||||
getExam: builder.query<Exam, string>({
|
||||
query: (id) => `/exams/${id}?i=questions`,
|
||||
// Nested relation so CHOICE questions carry their options here too —
|
||||
// needed to print real answer choices instead of blank A/B/C/D lines.
|
||||
query: (id) => `/exams/${id}?i=questions,questions.options`,
|
||||
providesTags: ['Api'],
|
||||
}),
|
||||
createExam: builder.mutation<Exam, CreateExamPayload>({
|
||||
@@ -90,6 +93,14 @@ const examApi = baseApi.injectEndpoints({
|
||||
}),
|
||||
invalidatesTags: ['Api'],
|
||||
}),
|
||||
/** Staff-triggered re-run of auto-grading for one finalized attempt. */
|
||||
regradeAttempt: builder.mutation<RegradeOutcome, string>({
|
||||
query: (attemptId) => ({
|
||||
url: `/exam-attempts/${attemptId}/regrade`,
|
||||
method: 'POST',
|
||||
}),
|
||||
invalidatesTags: ['Api'],
|
||||
}),
|
||||
}),
|
||||
overrideExisting: false,
|
||||
});
|
||||
@@ -107,4 +118,5 @@ export const {
|
||||
useGetExamIncidentsQuery,
|
||||
useRecordIncidentMutation,
|
||||
useResolveIncidentMutation,
|
||||
useRegradeAttemptMutation,
|
||||
} = examApi;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Badge, Button, Text } from '@mantine/core';
|
||||
import { IconUserCheck } from '@tabler/icons-react';
|
||||
import { ActionIcon, Badge, Menu, Text } from '@mantine/core';
|
||||
import { IconDotsVertical, IconRefresh, IconUserCheck } from '@tabler/icons-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||
@@ -25,7 +25,11 @@ export const candidateName = (registration: ExamRegistration) =>
|
||||
|
||||
export function examCandidateColumns(
|
||||
t: TFunction,
|
||||
handlers: { onRecord: (registration: ExamRegistration) => void },
|
||||
handlers: {
|
||||
onRecord: (registration: ExamRegistration) => void;
|
||||
onRegrade: (registration: ExamRegistration) => void;
|
||||
regrading?: string | null;
|
||||
},
|
||||
): AdvancedColumn<ExamRegistration>[] {
|
||||
return [
|
||||
{
|
||||
@@ -78,21 +82,51 @@ export function examCandidateColumns(
|
||||
header: '',
|
||||
label: t('exam.candidates.record'),
|
||||
align: 'right',
|
||||
cell: ({ row }) => (
|
||||
<RequirePermission
|
||||
anyOf={[LICENSE_PERMISSIONS.RECORD_EXAM_ATTENDANCE]}
|
||||
hideOnly
|
||||
>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
leftSection={<IconUserCheck size={12} />}
|
||||
onClick={() => handlers.onRecord(row.original)}
|
||||
>
|
||||
{t('exam.candidates.record')}
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const attemptStatus = row.original.attempt?.status;
|
||||
const canRegrade = attemptStatus === 'SUBMITTED' || attemptStatus === 'EXPIRED';
|
||||
return (
|
||||
<Menu shadow="md" width={180} position="bottom-end">
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
loading={handlers.regrading === row.original.attempt?.id}
|
||||
>
|
||||
<IconDotsVertical size={16} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<RequirePermission
|
||||
anyOf={[LICENSE_PERMISSIONS.RECORD_EXAM_ATTENDANCE]}
|
||||
hideOnly
|
||||
>
|
||||
<Menu.Item
|
||||
leftSection={<IconUserCheck size={14} />}
|
||||
onClick={() => handlers.onRecord(row.original)}
|
||||
>
|
||||
{t('exam.candidates.record')}
|
||||
</Menu.Item>
|
||||
</RequirePermission>
|
||||
{canRegrade && (
|
||||
<RequirePermission
|
||||
anyOf={[LICENSE_PERMISSIONS.RECORD_EXAM_RESULT]}
|
||||
hideOnly
|
||||
>
|
||||
<Menu.Item
|
||||
color="grape"
|
||||
leftSection={<IconRefresh size={14} />}
|
||||
onClick={() => handlers.onRegrade(row.original)}
|
||||
>
|
||||
{t('exam.candidates.regrade')}
|
||||
</Menu.Item>
|
||||
</RequirePermission>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { extractErrorMessage } from '@ema-platform/api';
|
||||
import {
|
||||
useGetExamRegistrationsQuery,
|
||||
useRecordAttendanceMutation,
|
||||
useRegradeAttemptMutation,
|
||||
} from '../../api/exam-api';
|
||||
import type { AttendanceStatus, ExamRegistration } from '../../types/exam';
|
||||
import { candidateName, examCandidateColumns } from './columns';
|
||||
@@ -43,11 +44,32 @@ export function ExamCandidatesPanel({ examId }: { examId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { data: registrations, isError, refetch } = useGetExamRegistrationsQuery(examId);
|
||||
const [recordAttendance, { isLoading }] = useRecordAttendanceMutation();
|
||||
const [regradeAttempt] = useRegradeAttemptMutation();
|
||||
const [regrading, setRegrading] = useState<string | null>(null);
|
||||
const [target, setTarget] = useState<ExamRegistration | null>(null);
|
||||
const [status, setStatus] = useState<AttendanceStatus>('PRESENT');
|
||||
const [remark, setRemark] = useState('');
|
||||
const table = useServerTable();
|
||||
|
||||
const regrade = async (registration: ExamRegistration) => {
|
||||
const attemptId = registration.attempt?.id;
|
||||
if (!attemptId) return;
|
||||
setRegrading(attemptId);
|
||||
try {
|
||||
const outcome = await regradeAttempt(attemptId).unwrap();
|
||||
if (outcome.graded) {
|
||||
notify.success(t('exam.candidates.regraded'));
|
||||
} else {
|
||||
notify.error(t('exam.candidates.regradeNotEligible', { reason: outcome.reason }));
|
||||
}
|
||||
refetch();
|
||||
} catch (error) {
|
||||
notify.error(extractErrorMessage(error, t('exam.candidates.regradeError')));
|
||||
} finally {
|
||||
setRegrading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const startRecording = (registration: ExamRegistration) => {
|
||||
setTarget(registration);
|
||||
setStatus(
|
||||
@@ -96,7 +118,11 @@ export function ExamCandidatesPanel({ examId }: { examId: string }) {
|
||||
) : (
|
||||
<AdvancedTable
|
||||
tableName={t('exam.candidates.section')}
|
||||
columns={examCandidateColumns(t, { onRecord: startRecording })}
|
||||
columns={examCandidateColumns(t, {
|
||||
onRecord: startRecording,
|
||||
onRegrade: regrade,
|
||||
regrading,
|
||||
})}
|
||||
data={paged.rows}
|
||||
itemCount={paged.itemCount}
|
||||
pageIndex={paged.pageIndex}
|
||||
|
||||
@@ -172,7 +172,12 @@ export function ExamDetailPage() {
|
||||
notify.error(
|
||||
key.startsWith('insufficient_approved_questions')
|
||||
? `${t('exam.notEnoughApproved')} (${key.split(':')[1] ?? ''})`
|
||||
: key,
|
||||
: key.startsWith('paper_cannot_reach_cutting_point')
|
||||
? t('exam.cannotReachCuttingPoint', {
|
||||
max: key.split(':')[1]?.split('/')[0] ?? '',
|
||||
cuttingPoint: key.split(':')[1]?.split('/')[1] ?? '',
|
||||
})
|
||||
: key,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -188,18 +193,35 @@ export function ExamDetailPage() {
|
||||
notify.error(
|
||||
key.startsWith('question_not_approved')
|
||||
? t('question.qc.onlyApprovedUsable')
|
||||
: key,
|
||||
: key.startsWith('paper_cannot_reach_cutting_point')
|
||||
? t('exam.cannotReachCuttingPoint', {
|
||||
max: key.split(':')[1]?.split('/')[0] ?? '',
|
||||
cuttingPoint: key.split(':')[1]?.split('/')[1] ?? '',
|
||||
})
|
||||
: key,
|
||||
);
|
||||
}
|
||||
};
|
||||
const handlePrint = async () => {
|
||||
const total = (exam.questions ?? []).reduce(
|
||||
(s, q) => s + Number(q.points),
|
||||
0,
|
||||
);
|
||||
if (total < Number(exam.cuttingPoint)) {
|
||||
// The reachable max depends on the evaluation method, not the raw point
|
||||
// sum — mirrors RecordResultModal's grading math so "can this paper pass"
|
||||
// means the same thing here as it does at marking time. Cutting point can
|
||||
// be raised after the paper was assembled (edit modal, no re-check on
|
||||
// save), so this still needs to run even though assignment now enforces
|
||||
// it too.
|
||||
const questions = exam.questions ?? [];
|
||||
const total = questions.reduce((s, q) => s + Number(q.points), 0);
|
||||
const reachableMax =
|
||||
exam.evaluationMethod === 'AVERAGE'
|
||||
? questions.length
|
||||
? total / questions.length
|
||||
: 0
|
||||
: exam.evaluationMethod === 'PERCENTAGE'
|
||||
? 100
|
||||
: total;
|
||||
if (reachableMax < 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.`,
|
||||
`This paper cannot reach the passing mark under its ${EVAL_LABEL[exam.evaluationMethod] ?? exam.evaluationMethod} evaluation (max ${reachableMax}, pass mark ${exam.cuttingPoint}). Add more questions or adjust the cutting point before printing.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -233,7 +255,23 @@ export function ExamDetailPage() {
|
||||
<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("") : ""}
|
||||
${
|
||||
q.form === "CHOICE"
|
||||
? q.options && q.options.length
|
||||
? q.options
|
||||
.slice()
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map(
|
||||
(o, oi) =>
|
||||
`<p style="margin: 4px 0; font-size: 13px;">${String.fromCharCode(65 + oi)}. ${o.text[locale] || o.text.en}</p>`,
|
||||
)
|
||||
.join("")
|
||||
// No options on record (legacy question, or options relation
|
||||
// wasn't loaded) — fall back to blank lines rather than
|
||||
// printing nothing.
|
||||
: ["A. ______", "B. ______", "C. ______", "D. ______"].map((l) => `<p style="margin: 4px 0; font-size: 13px;">${l}</p>`).join("")
|
||||
: ""
|
||||
}
|
||||
</div>`;
|
||||
})
|
||||
.join("");
|
||||
@@ -248,7 +286,20 @@ export function ExamDetailPage() {
|
||||
.header p { margin: 2px 0; font-size: 13px; color: #555; }
|
||||
.directions { background: #f5f5f5; padding: 12px 16px; border-radius: 4px; margin-bottom: 24px; font-size: 13px; }
|
||||
.directions strong { display: block; margin-bottom: 4px; }
|
||||
@media print { @page { margin: 20mm; } body { -webkit-print-color-adjust: exact; } }
|
||||
.footer { margin-top: 40px; border-top: 1px solid #ccc; padding-top: 12px; font-size: 12px; color: #888; text-align: center; }
|
||||
/* Pinned to the bottom of every printed page (not just after the
|
||||
last question) — @page's bottom margin leaves room for it so it
|
||||
never overlaps question text on the last page. */
|
||||
@media print {
|
||||
@page { margin: 20mm 20mm 28mm 20mm; }
|
||||
/* @page's margin already insets content from the physical page
|
||||
edge — body's own 40px padding (needed on-screen, for the
|
||||
preview tab before printing) would double up with it here,
|
||||
wasting real page height on every side and fitting noticeably
|
||||
fewer questions per page than the paper actually has room for. */
|
||||
body { -webkit-print-color-adjust: exact; padding: 0; max-width: none; }
|
||||
.footer { position: fixed; bottom: 0; left: 0; right: 0; margin-top: 0; }
|
||||
}
|
||||
</style></head><body>
|
||||
<div class="header">
|
||||
${logoBase64 ? `<img src="${logoBase64}" alt="Logo" class="header-logo" />` : ""}
|
||||
@@ -259,7 +310,7 @@ export function ExamDetailPage() {
|
||||
</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;">
|
||||
<div class="footer">
|
||||
Generated by EMA — Ethiopian Maritime Authority
|
||||
</div>
|
||||
</body></html>
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { ActionIcon, Group } from "@mantine/core";
|
||||
import { IconEdit, IconTrash, IconDetails } from "@tabler/icons-react";
|
||||
import { ActionIcon, Menu } from "@mantine/core";
|
||||
import {
|
||||
IconDetails,
|
||||
IconDotsVertical,
|
||||
IconEdit,
|
||||
IconTrash,
|
||||
IconToggleRight,
|
||||
} from "@tabler/icons-react";
|
||||
import type { TFunction } from "i18next";
|
||||
import type { AdvancedColumn } from "@ema-platform/ui";
|
||||
import { LICENSE_PERMISSIONS, RequirePermission } from "@ema-platform/auth";
|
||||
@@ -11,40 +17,55 @@ export function examActionsColumn(
|
||||
onEdit: (exam: Exam) => void;
|
||||
onDelete: (exam: Exam) => void;
|
||||
onDetails: (exam: Exam) => void;
|
||||
onOpenStatusChange: (exam: Exam) => void;
|
||||
changingStatusId?: string | null;
|
||||
},
|
||||
): AdvancedColumn<Exam> {
|
||||
return {
|
||||
header: t("exam.columns.actions"),
|
||||
header: t("exam.columns.actions", "Actions"),
|
||||
align: "right",
|
||||
cell: ({ row }) => (
|
||||
<Group gap="xs">
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_EXAMS]} hideOnly>
|
||||
<Menu shadow="md" width={180} position="bottom-end">
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="blue"
|
||||
color="gray"
|
||||
size="sm"
|
||||
onClick={() => handlers.onEdit(row.original)}
|
||||
loading={handlers.changingStatusId === row.original.id}
|
||||
>
|
||||
<IconEdit size={14} />
|
||||
<IconDotsVertical size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="sm"
|
||||
onClick={() => handlers.onDelete(row.original)}
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<IconDetails size={14} />}
|
||||
onClick={() => handlers.onDetails(row.original)}
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</RequirePermission>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="sm"
|
||||
onClick={() => handlers.onDetails(row.original)}
|
||||
>
|
||||
<IconDetails size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
{t("exam.action.details", "Details")}
|
||||
</Menu.Item>
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_EXAMS]} hideOnly>
|
||||
<Menu.Item
|
||||
leftSection={<IconEdit size={14} />}
|
||||
onClick={() => handlers.onEdit(row.original)}
|
||||
>
|
||||
{t("exam.action.edit", "Edit")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconToggleRight size={14} />}
|
||||
onClick={() => handlers.onOpenStatusChange(row.original)}
|
||||
>
|
||||
{t("exam.form.status")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<IconTrash size={14} />}
|
||||
onClick={() => handlers.onDelete(row.original)}
|
||||
>
|
||||
{t("exam.action.delete", "Delete")}
|
||||
</Menu.Item>
|
||||
</RequirePermission>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -76,21 +76,23 @@ function ExamForm({
|
||||
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 ||
|
||||
!type ||
|
||||
!form ||
|
||||
!venue ||
|
||||
!adminMethod ||
|
||||
!evalMethod
|
||||
) {
|
||||
notify.error("Please fill all required fields");
|
||||
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(
|
||||
@@ -120,7 +122,7 @@ function ExamForm({
|
||||
return (
|
||||
<Modal opened onClose={onCancel} title={editing ? t("exam.update") : t("exam.add")} size="xl">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Tabs defaultValue="basic" variant="outline" radius="md">
|
||||
<Tabs value={activeTab} onChange={setActiveTab} variant="outline" radius="md">
|
||||
<Tabs.List mb="md">
|
||||
<Tabs.Tab value="basic" leftSection={<IconInfoCircle size={15} />}>
|
||||
{t("exam.form.basicInfo")}
|
||||
@@ -251,6 +253,12 @@ function ExamForm({
|
||||
onChange={setForm}
|
||||
size="sm"
|
||||
required
|
||||
disabled={adminMethod === "ONLINE"}
|
||||
description={
|
||||
adminMethod === "ONLINE"
|
||||
? t("exam.form.onlineChoiceOnlyHint")
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
label={t("exam.detail.administration")}
|
||||
@@ -260,7 +268,14 @@ function ExamForm({
|
||||
{ value: "ONLINE", label: t("exam.form.online") },
|
||||
]}
|
||||
value={adminMethod}
|
||||
onChange={setAdminMethod}
|
||||
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
|
||||
/>
|
||||
@@ -290,12 +305,22 @@ function ExamForm({
|
||||
/>
|
||||
<NumberInput
|
||||
label={t("exam.form.cuttingPoint")}
|
||||
placeholder={t("exam.form.cuttingPointPlaceholder")}
|
||||
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"
|
||||
required
|
||||
withAsterisk
|
||||
description={
|
||||
evalMethod === "PERCENTAGE"
|
||||
? t("exam.form.cuttingPointPercentageHint")
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
{editing && (
|
||||
@@ -352,6 +377,11 @@ export function ExamPage() {
|
||||
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);
|
||||
|
||||
const certOptions = certifications
|
||||
.filter((c) => c.isActive)
|
||||
@@ -402,6 +432,21 @@ export function ExamPage() {
|
||||
}
|
||||
};
|
||||
|
||||
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 {
|
||||
@@ -429,6 +474,12 @@ export function ExamPage() {
|
||||
openDelete();
|
||||
},
|
||||
onDetails: (exam) => navigate(`/exams/${exam.id}`),
|
||||
onOpenStatusChange: (exam) => {
|
||||
setStatusTarget(exam);
|
||||
setPendingStatus(exam.status);
|
||||
openStatus();
|
||||
},
|
||||
changingStatusId,
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -503,6 +554,47 @@ export function ExamPage() {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,11 +15,19 @@ export type ExamStatus =
|
||||
| "POSTPONED"
|
||||
| "PUBLISHED";
|
||||
|
||||
/** Only populated when the exam is fetched with `?i=questions,questions.options`. */
|
||||
export interface QuestionOptionBrief {
|
||||
id: string;
|
||||
text: LocalePair;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export interface QuestionBrief {
|
||||
id: string;
|
||||
title: LocalePair;
|
||||
form: QuestionForm;
|
||||
points: number;
|
||||
options?: QuestionOptionBrief[];
|
||||
}
|
||||
|
||||
export interface Exam {
|
||||
@@ -118,8 +126,14 @@ export interface ExamRegistration {
|
||||
lastName: string | null;
|
||||
seafarerNumber: string | null;
|
||||
};
|
||||
/** The candidate's online sitting, when one has been started. */
|
||||
attempt?: { id: string; status: 'IN_PROGRESS' | 'SUBMITTED' | 'EXPIRED' } | null;
|
||||
}
|
||||
|
||||
export type RegradeOutcome =
|
||||
| { graded: true; resultId: string }
|
||||
| { graded: false; reason: string };
|
||||
|
||||
export interface RecordAttendancePayload {
|
||||
registrationId: string;
|
||||
status: AttendanceStatus;
|
||||
|
||||
Reference in New Issue
Block a user