From 8239d7ebe230b378954c142dcabc045870aa9a0b Mon Sep 17 00:00:00 2001 From: mihretue Date: Mon, 17 Aug 2026 12:20:21 +0000 Subject: [PATCH] feat(exam,result): quick status action, per-row publish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../features/exam/pages/ExamPage/actions.tsx | 41 +++++++++++++++++-- .../features/exam/pages/ExamPage/index.tsx | 15 +++++++ .../result/pages/ResultPage/actions.tsx | 16 +++++++- .../result/pages/ResultPage/index.tsx | 30 ++++++++++++++ apps/backoffice/src/app/i18n/locales/am.ts | 2 + apps/backoffice/src/app/i18n/locales/en.ts | 2 + 6 files changed, 102 insertions(+), 4 deletions(-) diff --git a/apps/backoffice/src/app/features/exam/pages/ExamPage/actions.tsx b/apps/backoffice/src/app/features/exam/pages/ExamPage/actions.tsx index ba902be9d..1b62e4283 100644 --- a/apps/backoffice/src/app/features/exam/pages/ExamPage/actions.tsx +++ b/apps/backoffice/src/app/features/exam/pages/ExamPage/actions.tsx @@ -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 { return { @@ -19,6 +30,30 @@ export function examActionsColumn( cell: ({ row }) => ( + + + + + + + + {t("exam.form.status")} + {STATUSES.map((status) => ( + handlers.onChangeStatus(row.original, status)} + > + {t(`exam.form.${status.toLowerCase()}`)} + + ))} + + (null); const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false); + const [changingStatusId, setChangingStatusId] = useState(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, }), ]; diff --git a/apps/backoffice/src/app/features/result/pages/ResultPage/actions.tsx b/apps/backoffice/src/app/features/result/pages/ResultPage/actions.tsx index d24ad573f..335c964cc 100644 --- a/apps/backoffice/src/app/features/result/pages/ResultPage/actions.tsx +++ b/apps/backoffice/src/app/features/result/pages/ResultPage/actions.tsx @@ -1,5 +1,5 @@ import { Button, Group } from '@mantine/core'; -import { IconEye, IconTrash } from '@tabler/icons-react'; +import { IconEye, IconSend, IconTrash } from '@tabler/icons-react'; import type { TFunction } from 'i18next'; import type { AdvancedColumn } from '@ema-platform/ui'; import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth'; @@ -13,6 +13,7 @@ export function resultActionsColumn( onQc: (result: Result, action: QcAction) => void; onViewDetail: (result: Result) => void; onDelete: (result: Result) => void; + onPublish: (result: Result) => void; }, ): AdvancedColumn { return { @@ -49,6 +50,19 @@ export function resultActionsColumn( )} + {r.reviewStatus === 'APPROVED' && ( + + + + )} diff --git a/apps/backoffice/src/app/features/result/pages/ResultPage/index.tsx b/apps/backoffice/src/app/features/result/pages/ResultPage/index.tsx index b9acb34ca..422555080 100644 --- a/apps/backoffice/src/app/features/result/pages/ResultPage/index.tsx +++ b/apps/backoffice/src/app/features/result/pages/ResultPage/index.tsx @@ -126,6 +126,8 @@ export function ResultPage() { const [detailOpened, { open: openDetail, close: closeDetail }] = useDisclosure(false); const [deleteTarget, setDeleteTarget] = useState(null); const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false); + const [publishTarget, setPublishTarget] = useState(null); + const [publishOpened, { open: openPublish, close: closePublish }] = useDisclosure(false); const [detailRemark, setDetailRemark] = useState({ en: '', am: '' }); const [detailBreakdowns, setDetailBreakdowns] = useState([]); const [detailSaving, setDetailSaving] = useState(false); @@ -262,6 +264,19 @@ export function ResultPage() { } }; + /** Same publish call as handlePublish, but scoped to one row's exam — no page filter needed. */ + const handleConfirmPublish = async () => { + if (!publishTarget) return; + try { + const outcome = await publishResults(publishTarget.examId).unwrap(); + notify.success(t('result.review.publishedCount', outcome)); + closePublish(); + setPublishTarget(null); + } catch (error) { + notify.error(extractErrorMessage(error, t('result.review.error'))); + } + }; + const handleDetailClose = () => { closeDetail(); setDetailBreakdowns([]); @@ -288,6 +303,7 @@ export function ResultPage() { onQc: openQc, onViewDetail: viewDetail, onDelete: (r) => { setDeleteTarget(r); openDelete(); }, + onPublish: (r) => { setPublishTarget(r); openPublish(); }, }), ]; @@ -584,6 +600,20 @@ export function ResultPage() { + + + {t('result.review.publishConfirmText', { + exam: publishTarget ? getExamTitle(publishTarget.examId) : '', + })} + + + + + + + {/* Choose exam, then record */} diff --git a/apps/backoffice/src/app/i18n/locales/am.ts b/apps/backoffice/src/app/i18n/locales/am.ts index 3ee42dd5a..b30de16b1 100644 --- a/apps/backoffice/src/app/i18n/locales/am.ts +++ b/apps/backoffice/src/app/i18n/locales/am.ts @@ -660,6 +660,8 @@ export const am: Translations = { returned: "ውጤት ወደ ፈታኙ ተመልሷል", publishedCount: "{{published}} ውጤቶች ወጥተዋል፤ {{skipped}} ተዘለዋል።", publishNeedsExam: "ውጤቶችን ለማውጣት መጀመሪያ በፈተና ያጣሩ።", + publishConfirmText: + "ይህ ለ{{exam}} የጸደቁትን ሁሉንም ውጤቶች ያወጣል — ይህን ብቻ አይደለም — እና እያንዳንዱን ተፈታኝ ያሳውቃል። ይቀጥል?", lockedAfterApproval: "ይህ ውጤት ጸድቋል፤ ማስተካከል አይቻልም። መጀመሪያ ወደ ፈታኙ ይመልሱት።", originalScore: "የፈታኙ ጠቅላላ", derivedStatus: "ውጤት (ከማለፊያ ነጥብ የተገኘ)", diff --git a/apps/backoffice/src/app/i18n/locales/en.ts b/apps/backoffice/src/app/i18n/locales/en.ts index 04baaf1d7..450768341 100644 --- a/apps/backoffice/src/app/i18n/locales/en.ts +++ b/apps/backoffice/src/app/i18n/locales/en.ts @@ -660,6 +660,8 @@ export const en = { returned: 'Result returned to the examiner', publishedCount: 'Published {{published}} result(s); {{skipped}} skipped.', publishNeedsExam: 'Filter by an exam first to publish its results.', + publishConfirmText: + 'This publishes every approved result for {{exam}} — not just this one — and notifies each candidate. Continue?', lockedAfterApproval: 'This result is approved and can no longer be edited. Return it to the examiner first.', originalScore: 'Examiner total',