Adding more functionalities for all the license types

This commit is contained in:
Mulu Mehari
2026-08-07 11:50:07 +03:00
parent 35fb817b4b
commit 7c968c7093
66 changed files with 6468 additions and 3518 deletions

View File

@@ -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;

View File

@@ -16,19 +16,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 {
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] }));
@@ -145,6 +170,40 @@ export function QuestionPage() {
}
};
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')));
}
};
const handleDelete = async () => {
if (!deleteTarget) return;
try {
@@ -193,7 +252,7 @@ export function QuestionPage() {
<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.columns.status')}</Table.Th>
<Table.Th>{t('question.qc.column')}</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
@@ -205,10 +264,54 @@ export function QuestionPage() {
<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={q.isActive ? 'teal' : 'gray'}>{q.isActive ? t('question.status.active') : t('question.status.inactive')}</Badge>
<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>
@@ -230,6 +333,38 @@ export function QuestionPage() {
</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>
<Group justify="flex-end">

View File

@@ -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[];