fix(exam,result,question): kebab-icon action menus, status change as a picker

Two follow-ups on the dropdown-actions pass:
- All four action menus (exams, results, questions, candidates panel) now
  trigger off a plain three-dot icon button instead of a text 'Actions'
  button with a chevron.
- Exam status change no longer lists all six statuses flat in the dropdown
  (bad UX, looked like six always-visible options). 'Status' is now one
  menu item that opens a small picker modal (current status + a Select +
  Update) rather than the full edit form.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
mihretue
2026-08-18 11:46:16 +00:00
parent 80db201676
commit ae3af7777e
5 changed files with 95 additions and 56 deletions

View File

@@ -1,5 +1,5 @@
import { Badge, Button, Menu, Text } from '@mantine/core'; import { ActionIcon, Badge, Menu, Text } from '@mantine/core';
import { IconChevronDown, IconRefresh, IconUserCheck } from '@tabler/icons-react'; import { IconDotsVertical, IconRefresh, IconUserCheck } from '@tabler/icons-react';
import type { TFunction } from 'i18next'; import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui'; import type { AdvancedColumn } from '@ema-platform/ui';
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth'; import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
@@ -88,14 +88,14 @@ export function examCandidateColumns(
return ( return (
<Menu shadow="md" width={180} position="bottom-end"> <Menu shadow="md" width={180} position="bottom-end">
<Menu.Target> <Menu.Target>
<Button <ActionIcon
size="compact-xs" variant="subtle"
variant="light" color="gray"
rightSection={<IconChevronDown size={12} />} size="sm"
loading={handlers.regrading === row.original.attempt?.id} loading={handlers.regrading === row.original.attempt?.id}
> >
{t('exam.candidates.record', 'Actions')} <IconDotsVertical size={16} />
</Button> </ActionIcon>
</Menu.Target> </Menu.Target>
<Menu.Dropdown> <Menu.Dropdown>
<RequirePermission <RequirePermission

View File

@@ -1,23 +1,15 @@
import { Button, Menu } from "@mantine/core"; import { ActionIcon, Menu } from "@mantine/core";
import { import {
IconChevronDown,
IconDetails, IconDetails,
IconDotsVertical,
IconEdit, IconEdit,
IconTrash, IconTrash,
IconToggleRight,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import type { TFunction } from "i18next"; import type { TFunction } from "i18next";
import type { AdvancedColumn } from "@ema-platform/ui"; import type { AdvancedColumn } from "@ema-platform/ui";
import { LICENSE_PERMISSIONS, RequirePermission } from "@ema-platform/auth"; import { LICENSE_PERMISSIONS, RequirePermission } from "@ema-platform/auth";
import type { Exam, ExamStatus } from "../../types/exam"; import type { Exam } from "../../types/exam";
const STATUSES: ExamStatus[] = [
"PENDING",
"ACTIVE",
"COMPLETED",
"CANCELLED",
"POSTPONED",
"PUBLISHED",
];
export function examActionsColumn( export function examActionsColumn(
t: TFunction, t: TFunction,
@@ -25,7 +17,7 @@ export function examActionsColumn(
onEdit: (exam: Exam) => void; onEdit: (exam: Exam) => void;
onDelete: (exam: Exam) => void; onDelete: (exam: Exam) => void;
onDetails: (exam: Exam) => void; onDetails: (exam: Exam) => void;
onChangeStatus: (exam: Exam, status: ExamStatus) => void; onOpenStatusChange: (exam: Exam) => void;
changingStatusId?: string | null; changingStatusId?: string | null;
}, },
): AdvancedColumn<Exam> { ): AdvancedColumn<Exam> {
@@ -33,16 +25,16 @@ export function examActionsColumn(
header: t("exam.columns.actions", "Actions"), header: t("exam.columns.actions", "Actions"),
align: "right", align: "right",
cell: ({ row }) => ( cell: ({ row }) => (
<Menu shadow="md" width={200} position="bottom-end"> <Menu shadow="md" width={180} position="bottom-end">
<Menu.Target> <Menu.Target>
<Button <ActionIcon
size="compact-xs" variant="subtle"
variant="light" color="gray"
rightSection={<IconChevronDown size={12} />} size="sm"
loading={handlers.changingStatusId === row.original.id} loading={handlers.changingStatusId === row.original.id}
> >
{t("exam.columns.actions", "Actions")} <IconDotsVertical size={16} />
</Button> </ActionIcon>
</Menu.Target> </Menu.Target>
<Menu.Dropdown> <Menu.Dropdown>
<Menu.Item <Menu.Item
@@ -58,6 +50,12 @@ export function examActionsColumn(
> >
{t("exam.action.edit", "Edit")} {t("exam.action.edit", "Edit")}
</Menu.Item> </Menu.Item>
<Menu.Item
leftSection={<IconToggleRight size={14} />}
onClick={() => handlers.onOpenStatusChange(row.original)}
>
{t("exam.form.status")}
</Menu.Item>
<Menu.Item <Menu.Item
color="red" color="red"
leftSection={<IconTrash size={14} />} leftSection={<IconTrash size={14} />}
@@ -65,17 +63,6 @@ export function examActionsColumn(
> >
{t("exam.action.delete", "Delete")} {t("exam.action.delete", "Delete")}
</Menu.Item> </Menu.Item>
<Menu.Divider />
<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>
))}
</RequirePermission> </RequirePermission>
</Menu.Dropdown> </Menu.Dropdown>
</Menu> </Menu>

View File

@@ -369,6 +369,10 @@ export function ExamPage() {
const [deleteOpened, { open: openDelete, close: closeDelete }] = const [deleteOpened, { open: openDelete, close: closeDelete }] =
useDisclosure(false); useDisclosure(false);
const [changingStatusId, setChangingStatusId] = useState<string | null>(null); 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 const certOptions = certifications
.filter((c) => c.isActive) .filter((c) => c.isActive)
@@ -419,11 +423,14 @@ export function ExamPage() {
} }
}; };
const handleChangeStatus = async (exam: Exam, status: Exam["status"]) => { const handleChangeStatus = async () => {
setChangingStatusId(exam.id); if (!statusTarget || !pendingStatus) return;
setChangingStatusId(statusTarget.id);
try { try {
await updateExam({ id: exam.id, status }).unwrap(); await updateExam({ id: statusTarget.id, status: pendingStatus }).unwrap();
notify.success(t("exam.updated")); notify.success(t("exam.updated"));
closeStatus();
setStatusTarget(null);
} catch (e) { } catch (e) {
handleError(e); handleError(e);
} finally { } finally {
@@ -464,7 +471,11 @@ export function ExamPage() {
openDelete(); openDelete();
}, },
onDetails: (exam) => navigate(`/exams/${exam.id}`), onDetails: (exam) => navigate(`/exams/${exam.id}`),
onChangeStatus: handleChangeStatus, onOpenStatusChange: (exam) => {
setStatusTarget(exam);
setPendingStatus(exam.status);
openStatus();
},
changingStatusId, changingStatusId,
}), }),
]; ];
@@ -541,6 +552,47 @@ export function ExamPage() {
</Button> </Button>
</ModalFooter> </ModalFooter>
</Modal> </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> </Stack>
); );
} }

View File

@@ -1,6 +1,6 @@
import { Button, Menu } from '@mantine/core'; import { ActionIcon, Menu } from '@mantine/core';
import { import {
IconChevronDown, IconDotsVertical,
IconEdit, IconEdit,
IconGavel, IconGavel,
IconSend, IconSend,
@@ -29,14 +29,14 @@ export function questionActionsColumn(
return ( return (
<Menu shadow="md" width={180} position="bottom-end"> <Menu shadow="md" width={180} position="bottom-end">
<Menu.Target> <Menu.Target>
<Button <ActionIcon
size="compact-xs" variant="subtle"
variant="light" color="gray"
rightSection={<IconChevronDown size={12} />} size="sm"
loading={handlers.isSubmittingReview} loading={handlers.isSubmittingReview}
> >
{t('question.columns.actions', 'Actions')} <IconDotsVertical size={16} />
</Button> </ActionIcon>
</Menu.Target> </Menu.Target>
<Menu.Dropdown> <Menu.Dropdown>
{(q.status === 'DRAFT' || q.status === 'REJECTED') && ( {(q.status === 'DRAFT' || q.status === 'REJECTED') && (

View File

@@ -1,5 +1,5 @@
import { Button, Menu } from '@mantine/core'; import { ActionIcon, Menu } from '@mantine/core';
import { IconChevronDown, IconEye, IconSend, IconTrash } from '@tabler/icons-react'; import { IconDotsVertical, IconEye, IconSend, IconTrash } from '@tabler/icons-react';
import type { TFunction } from 'i18next'; import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui'; import type { AdvancedColumn } from '@ema-platform/ui';
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth'; import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
@@ -24,9 +24,9 @@ export function resultActionsColumn(
return ( return (
<Menu shadow="md" width={180} position="bottom-end"> <Menu shadow="md" width={180} position="bottom-end">
<Menu.Target> <Menu.Target>
<Button size="compact-xs" variant="light" rightSection={<IconChevronDown size={12} />}> <ActionIcon variant="subtle" color="gray" size="sm">
{t('result.columns.actions', 'Actions')} <IconDotsVertical size={16} />
</Button> </ActionIcon>
</Menu.Target> </Menu.Target>
<Menu.Dropdown> <Menu.Dropdown>
<Menu.Item leftSection={<IconEye size={14} />} onClick={() => handlers.onViewDetail(r)}> <Menu.Item leftSection={<IconEye size={14} />} onClick={() => handlers.onViewDetail(r)}>