mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-29 07:11:00 +00:00
Merge branch 'dev' of https://github.com/Tria-plc/emaui into estif-branch-1
This commit is contained in:
@@ -4,6 +4,7 @@ import type {
|
||||
ListResponse,
|
||||
CreateQuestionPayload,
|
||||
UpdateQuestionPayload,
|
||||
ReviewQuestionPayload,
|
||||
} from '../types/question';
|
||||
|
||||
const questionApi = baseApi.injectEndpoints({
|
||||
@@ -32,6 +33,20 @@ const questionApi = baseApi.injectEndpoints({
|
||||
query: (id) => ({ url: `/questions/${id}`, method: 'DELETE' }),
|
||||
invalidatesTags: ['Api'],
|
||||
}),
|
||||
/** Hand a drafted or corrected item to the supervisor (US-EXAM-003). */
|
||||
submitQuestion: builder.mutation<Question, string>({
|
||||
query: (id) => ({ url: `/questions/${id}/submit`, method: 'POST' }),
|
||||
invalidatesTags: ['Api'],
|
||||
}),
|
||||
/** Approve, reject or retire a bank item (US-EXAM-003). */
|
||||
reviewQuestion: builder.mutation<Question, ReviewQuestionPayload>({
|
||||
query: ({ id, ...body }) => ({
|
||||
url: `/questions/${id}/review`,
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: ['Api'],
|
||||
}),
|
||||
}),
|
||||
overrideExisting: false,
|
||||
});
|
||||
@@ -42,4 +57,6 @@ export const {
|
||||
useCreateQuestionMutation,
|
||||
useUpdateQuestionMutation,
|
||||
useDeleteQuestionMutation,
|
||||
useSubmitQuestionMutation,
|
||||
useReviewQuestionMutation,
|
||||
} = questionApi;
|
||||
|
||||
@@ -14,19 +14,39 @@ import {
|
||||
Alert,
|
||||
Select,
|
||||
NumberInput,
|
||||
Textarea,
|
||||
} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IconEdit, IconTrash, IconPlus, IconInfoCircle } from '@tabler/icons-react';
|
||||
import { notify, useErrorHandler, AdvancedTable, useServerTable, ModalFooter, type AdvancedColumn } from '@ema-platform/ui';
|
||||
import {
|
||||
IconEdit,
|
||||
IconTrash,
|
||||
IconPlus,
|
||||
IconInfoCircle,
|
||||
IconSend,
|
||||
IconGavel,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { extractErrorMessage } from '@ema-platform/api';
|
||||
import { useGetCertificationsQuery } from '../../certification/api/certification-api';
|
||||
import {
|
||||
useGetQuestionsQuery,
|
||||
useCreateQuestionMutation,
|
||||
useUpdateQuestionMutation,
|
||||
useDeleteQuestionMutation,
|
||||
useSubmitQuestionMutation,
|
||||
useReviewQuestionMutation,
|
||||
} from '../api/question-api';
|
||||
import type { Question, QuestionForm } from '../types/question';
|
||||
import type { Question, QuestionForm, QuestionStatus } from '../types/question';
|
||||
|
||||
/** Bank items travel DRAFT → PENDING_APPROVAL → APPROVED (US-EXAM-003). */
|
||||
const QC_COLOR: Record<QuestionStatus, string> = {
|
||||
DRAFT: 'gray',
|
||||
PENDING_APPROVAL: 'yellow',
|
||||
APPROVED: 'teal',
|
||||
REJECTED: 'red',
|
||||
RETIRED: 'dark',
|
||||
};
|
||||
|
||||
function QuestionForm({
|
||||
editing,
|
||||
@@ -107,6 +127,8 @@ export function QuestionPage() {
|
||||
const [createQ, { isLoading: isCreating }] = useCreateQuestionMutation();
|
||||
const [updateQ, { isLoading: isUpdating }] = useUpdateQuestionMutation();
|
||||
const [deleteQ] = useDeleteQuestionMutation();
|
||||
const [submitQ, { isLoading: isSubmittingReview }] = useSubmitQuestionMutation();
|
||||
const [reviewQ, { isLoading: isReviewing }] = useReviewQuestionMutation();
|
||||
|
||||
const certifications = certRes?.items ?? [];
|
||||
const questions = data?.items ?? [];
|
||||
@@ -116,6 +138,9 @@ export function QuestionPage() {
|
||||
const [deleteTarget, setDeleteTarget] = useState<Question | null>(null);
|
||||
const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false);
|
||||
const [certFilter, setCertFilter] = useState<string | null>(null);
|
||||
const [reviewTarget, setReviewTarget] = useState<Question | null>(null);
|
||||
const [reviewOutcome, setReviewOutcome] = useState<'APPROVED' | 'REJECTED' | 'RETIRED'>('APPROVED');
|
||||
const [reviewRemark, setReviewRemark] = useState('');
|
||||
|
||||
const certOptions = certifications.filter((c) => c.isActive).map((c) => ({ value: c.id, label: c.name[locale] }));
|
||||
|
||||
@@ -141,8 +166,42 @@ export function QuestionPage() {
|
||||
notify.success(t('question.created'));
|
||||
}
|
||||
resetForm();
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
} catch {
|
||||
notify.error(t('question.error'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmitForApproval = async (question: Question) => {
|
||||
try {
|
||||
await submitQ(question.id).unwrap();
|
||||
notify.success(t('question.qc.submitted'));
|
||||
} catch (error) {
|
||||
notify.error(extractErrorMessage(error, t('question.qc.error')));
|
||||
}
|
||||
};
|
||||
|
||||
const openReview = (question: Question, outcome: 'APPROVED' | 'REJECTED' | 'RETIRED') => {
|
||||
setReviewTarget(question);
|
||||
setReviewOutcome(outcome);
|
||||
setReviewRemark('');
|
||||
};
|
||||
|
||||
const handleReview = async () => {
|
||||
if (!reviewTarget) return;
|
||||
if (reviewOutcome !== 'APPROVED' && !reviewRemark.trim()) {
|
||||
notify.error(t('question.qc.remarkRequired'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await reviewQ({
|
||||
id: reviewTarget.id,
|
||||
outcome: reviewOutcome,
|
||||
remark: reviewRemark.trim() || undefined,
|
||||
}).unwrap();
|
||||
notify.success(t('question.qc.reviewed'));
|
||||
setReviewTarget(null);
|
||||
} catch (error) {
|
||||
notify.error(extractErrorMessage(error, t('question.qc.error')));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -239,19 +298,125 @@ export function QuestionPage() {
|
||||
clearable
|
||||
/>
|
||||
</Group>
|
||||
<AdvancedTable
|
||||
columns={columns}
|
||||
data={page.rows}
|
||||
tableName={t('question.title')}
|
||||
itemCount={page.itemCount}
|
||||
pageIndex={page.pageIndex}
|
||||
onPageChange={setPageIndex}
|
||||
pageSize={pageSize}
|
||||
refresh={refetch}
|
||||
isLoading={isFetching}
|
||||
emptyText={t('question.noQuestions')}
|
||||
/>
|
||||
</Card>
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
||||
<Table.Tr>
|
||||
<Table.Th>{t('question.columns.title')}</Table.Th>
|
||||
<Table.Th>{t('question.columns.certification')}</Table.Th>
|
||||
<Table.Th>{t('question.columns.form')}</Table.Th>
|
||||
<Table.Th>{t('question.columns.points')}</Table.Th>
|
||||
<Table.Th>{t('question.qc.column')}</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{filtered.map((q) => (
|
||||
<Table.Tr key={q.id}>
|
||||
<Table.Td><Text fz="sm" maw={300} lineClamp={2}>{q.title[locale]}</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm">{getCertName(q.certificationId)}</Text></Table.Td>
|
||||
<Table.Td><Badge size="sm" variant="light" color={q.form === 'ESSAY' ? 'blue' : 'violet'}>{t(`question.form.${q.form === 'ESSAY' ? 'essay' : 'choice'}`)}</Badge></Table.Td>
|
||||
<Table.Td><Text fz="sm" fw={600}>{q.points}</Text></Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" variant="light" color={QC_COLOR[q.status] ?? 'gray'} title={q.reviewRemark ?? undefined}>
|
||||
{t(`question.qc.${q.status}`)}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
{(q.status === 'DRAFT' || q.status === 'REJECTED') && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
leftSection={<IconSend size={12} />}
|
||||
loading={isSubmittingReview}
|
||||
onClick={() => handleSubmitForApproval(q)}
|
||||
>
|
||||
{t('question.qc.submit')}
|
||||
</Button>
|
||||
)}
|
||||
{q.status === 'PENDING_APPROVAL' && (
|
||||
<>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="teal"
|
||||
onClick={() => openReview(q, 'APPROVED')}
|
||||
>
|
||||
{t('question.qc.approve')}
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="red"
|
||||
onClick={() => openReview(q, 'REJECTED')}
|
||||
>
|
||||
{t('question.qc.reject')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{q.status === 'APPROVED' && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
color="dark"
|
||||
leftSection={<IconGavel size={12} />}
|
||||
onClick={() => openReview(q, 'RETIRED')}
|
||||
>
|
||||
{t('question.qc.retire')}
|
||||
</Button>
|
||||
)}
|
||||
<ActionIcon variant="subtle" color="blue" size="sm" onClick={() => { setEditing(q); setShowForm(true); }}>
|
||||
<IconEdit size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => { setDeleteTarget(q); openDelete(); }}>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={6}>
|
||||
<Text c="dimmed" ta="center" py="xl">{t('question.noQuestions')}</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
|
||||
<Modal
|
||||
opened={Boolean(reviewTarget)}
|
||||
onClose={() => setReviewTarget(null)}
|
||||
title={t('question.qc.reviewTitle')}
|
||||
size="md"
|
||||
radius="lg"
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Text fz="sm" fw={500}>{reviewTarget?.title?.[locale]}</Text>
|
||||
<Text fz="xs" c="dimmed">{t('question.qc.onlyApprovedUsable')}</Text>
|
||||
<Badge variant="light" color={reviewOutcome === 'APPROVED' ? 'teal' : reviewOutcome === 'REJECTED' ? 'red' : 'dark'} w="fit-content">
|
||||
{t(`question.qc.${reviewOutcome}`)}
|
||||
</Badge>
|
||||
<Textarea
|
||||
label={t('question.qc.remark')}
|
||||
minRows={3}
|
||||
autosize
|
||||
value={reviewRemark}
|
||||
onChange={(e) => setReviewRemark(e.currentTarget.value)}
|
||||
required={reviewOutcome !== 'APPROVED'}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" size="sm" onClick={() => setReviewTarget(null)}>
|
||||
{t('question.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" loading={isReviewing} onClick={handleReview}>
|
||||
{t(`question.qc.${reviewOutcome === 'APPROVED' ? 'approve' : reviewOutcome === 'REJECTED' ? 'reject' : 'retire'}`)}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<Modal opened={deleteOpened} onClose={closeDelete} title={t('question.confirmDelete')} size="sm">
|
||||
<Text mb="md">{t('question.deleteConfirmText')}</Text>
|
||||
|
||||
@@ -8,6 +8,14 @@ export interface EstimatedTime {
|
||||
minutes: number;
|
||||
}
|
||||
|
||||
/** Question bank quality control (US-EXAM-003). */
|
||||
export type QuestionStatus =
|
||||
| 'DRAFT'
|
||||
| 'PENDING_APPROVAL'
|
||||
| 'APPROVED'
|
||||
| 'REJECTED'
|
||||
| 'RETIRED';
|
||||
|
||||
export interface Question {
|
||||
id: string;
|
||||
certificationId: string;
|
||||
@@ -18,10 +26,20 @@ export interface Question {
|
||||
time: EstimatedTime | null;
|
||||
points: number;
|
||||
isActive: boolean;
|
||||
status: QuestionStatus;
|
||||
reviewRemark: string | null;
|
||||
reviewedAt: string | null;
|
||||
submittedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ReviewQuestionPayload {
|
||||
id: string;
|
||||
outcome: 'APPROVED' | 'REJECTED' | 'RETIRED';
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export interface ListResponse<T> {
|
||||
total: number;
|
||||
items: T[];
|
||||
|
||||
Reference in New Issue
Block a user