feat(exam,result): quick status action, per-row publish

Two UX gaps found in testing:

- Exams list: changing status (e.g. PENDING -> ACTIVE) required opening the
  full Edit modal for a one-field change. Added a status menu (teal toggle
  icon) directly in the row actions, calling PUT /exams/:id with just
  {status} — the backend DTO was already PartialType, nothing to change
  there.
- Exam Results: publishing required first picking the exam in a page-level
  filter to un-grey the header Publish button — not discoverable, and an
  extra step disconnected from the row you actually care about. Added a
  Publish action directly on any APPROVED result's row, confirm-gated since
  it publishes every approved result for that exam (not just the one row)
  and notifies every candidate. The header filter+button still works too,
  for publishing a batch at once.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
mihretue
2026-08-17 12:20:21 +00:00
parent 83d309248d
commit 8239d7ebe2
6 changed files with 102 additions and 4 deletions

View File

@@ -1,9 +1,18 @@
import { ActionIcon, Group } from "@mantine/core";
import { IconEdit, IconTrash, IconDetails } from "@tabler/icons-react";
import { ActionIcon, Group, Menu } from "@mantine/core";
import { IconEdit, IconTrash, IconDetails, 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";
import type { Exam } from "../../types/exam";
import type { Exam, ExamStatus } from "../../types/exam";
const STATUSES: ExamStatus[] = [
"PENDING",
"ACTIVE",
"COMPLETED",
"CANCELLED",
"POSTPONED",
"PUBLISHED",
];
export function examActionsColumn(
t: TFunction,
@@ -11,6 +20,8 @@ export function examActionsColumn(
onEdit: (exam: Exam) => void;
onDelete: (exam: Exam) => void;
onDetails: (exam: Exam) => void;
onChangeStatus: (exam: Exam, status: ExamStatus) => void;
changingStatusId?: string | null;
},
): AdvancedColumn<Exam> {
return {
@@ -19,6 +30,30 @@ export function examActionsColumn(
cell: ({ row }) => (
<Group gap="xs">
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_EXAMS]} hideOnly>
<Menu shadow="md" width={160} position="bottom-end">
<Menu.Target>
<ActionIcon
variant="subtle"
color="teal"
size="sm"
loading={handlers.changingStatusId === row.original.id}
>
<IconToggleRight size={14} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>{t("exam.form.status")}</Menu.Label>
{STATUSES.map((status) => (
<Menu.Item
key={status}
disabled={status === row.original.status}
onClick={() => handlers.onChangeStatus(row.original, status)}
>
{t(`exam.form.${status.toLowerCase()}`)}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
<ActionIcon
variant="subtle"
color="blue"

View File

@@ -355,6 +355,7 @@ 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 certOptions = certifications
.filter((c) => c.isActive)
@@ -405,6 +406,18 @@ export function ExamPage() {
}
};
const handleChangeStatus = async (exam: Exam, status: Exam["status"]) => {
setChangingStatusId(exam.id);
try {
await updateExam({ id: exam.id, status }).unwrap();
notify.success(t("exam.updated"));
} catch (e) {
handleError(e);
} finally {
setChangingStatusId(null);
}
};
const handleDelete = async () => {
if (!deleteTarget) return;
try {
@@ -438,6 +451,8 @@ export function ExamPage() {
openDelete();
},
onDetails: (exam) => navigate(`/exams/${exam.id}`),
onChangeStatus: handleChangeStatus,
changingStatusId,
}),
];