feat: added exam modules

This commit is contained in:
mengstabketemaw
2026-06-26 12:10:38 +03:00
parent ba4cb63423
commit d7420d2de6
16 changed files with 1959 additions and 0 deletions

View File

@@ -0,0 +1,45 @@
import { baseApi } from '@ema-platform/api';
import type {
Question,
ListResponse,
CreateQuestionPayload,
UpdateQuestionPayload,
} from '../types/question';
const questionApi = baseApi.injectEndpoints({
endpoints: (builder) => ({
getQuestions: builder.query<ListResponse<Question>, void>({
query: () => '/questions',
providesTags: ['Api'],
}),
getQuestion: builder.query<Question, string>({
query: (id) => `/questions/${id}`,
providesTags: ['Api'],
}),
createQuestion: builder.mutation<Question, CreateQuestionPayload>({
query: (body) => ({ url: '/questions', method: 'POST', body }),
invalidatesTags: ['Api'],
}),
updateQuestion: builder.mutation<Question, UpdateQuestionPayload>({
query: ({ id, ...body }) => ({
url: `/questions/${id}`,
method: 'PUT',
body,
}),
invalidatesTags: ['Api'],
}),
deleteQuestion: builder.mutation<void, string>({
query: (id) => ({ url: `/questions/${id}`, method: 'DELETE' }),
invalidatesTags: ['Api'],
}),
}),
overrideExisting: false,
});
export const {
useGetQuestionsQuery,
useGetQuestionQuery,
useCreateQuestionMutation,
useUpdateQuestionMutation,
useDeleteQuestionMutation,
} = questionApi;

View File

@@ -0,0 +1,239 @@
import { useState } from 'react';
import {
Stack,
Title,
Group,
Button,
Table,
Badge,
ActionIcon,
Modal,
Text,
TextInput,
Textarea,
Paper,
Loader,
Center,
Alert,
Select,
NumberInput,
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { IconEdit, IconTrash, IconPlus, IconInfoCircle } from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { useGetCertificationsQuery } from '../../certification/api/certification-api';
import {
useGetQuestionsQuery,
useCreateQuestionMutation,
useUpdateQuestionMutation,
useDeleteQuestionMutation,
} from '../api/question-api';
import type { Question, QuestionForm } from '../types/question';
function QuestionForm({
editing,
certOptions,
isSubmitting,
onSubmit,
onCancel,
}: {
editing: Question | null;
certOptions: { value: string; label: string }[];
isSubmitting: boolean;
onSubmit: (values: {
certificationId: string;
titleEn: string;
titleAm: string;
form: string;
points: number;
days: number;
hours: number;
minutes: number;
}, isEdit: boolean) => void;
onCancel: () => void;
}) {
const [certificationId, setCertificationId] = useState<string | null>(editing?.certificationId ?? null);
const [titleEn, setTitleEn] = useState(editing?.title?.en ?? '');
const [titleAm, setTitleAm] = useState(editing?.title?.am ?? '');
const [form, setForm] = useState<string | null>(editing?.form ?? null);
const [points, setPoints] = useState<number>(editing?.points ?? 0);
const [days, setDays] = useState(editing?.time?.days ?? 0);
const [hours, setHours] = useState(editing?.time?.hours ?? 0);
const [minutes, setMinutes] = useState(editing?.time?.minutes ?? 0);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!certificationId || !titleEn || !titleAm || !form) {
notify.error('Please fill all required fields');
return;
}
onSubmit({ certificationId, titleEn, titleAm, form, points, days, hours, minutes }, !!editing);
};
return (
<Paper p="md" withBorder mb="md" radius="md">
<form onSubmit={handleSubmit}>
<Stack gap="sm">
<Select label="Certification" placeholder="Select certification" data={certOptions} value={certificationId} onChange={setCertificationId} size="sm" searchable required />
<TextInput label="Title (English)" placeholder="Question in English" value={titleEn} onChange={(e) => setTitleEn(e.currentTarget.value)} size="sm" required />
<TextInput label="Title (Amharic)" placeholder="ጥያቄ በአማርኛ" value={titleAm} onChange={(e) => setTitleAm(e.currentTarget.value)} size="sm" required />
<Select label="Form" placeholder="Select form" data={[{ value: 'ESSAY', label: 'Essay' }, { value: 'CHOICE', label: 'Choice' }]} value={form} onChange={setForm} size="sm" required />
<NumberInput label="Points" placeholder="Points" value={points} onChange={(v) => setPoints(Number(v))} min={0} size="sm" required />
<Text fz="sm" fw={500}>Time Allowed</Text>
<Group gap="sm" grow>
<NumberInput label="Days" value={days} onChange={(v) => setDays(Number(v))} min={0} size="sm" />
<NumberInput label="Hours" value={hours} onChange={(v) => setHours(Number(v))} min={0} size="sm" />
<NumberInput label="Minutes" value={minutes} onChange={(v) => setMinutes(Number(v))} min={0} size="sm" />
</Group>
<Group justify="flex-end">
<Button variant="default" onClick={onCancel} size="sm">Cancel</Button>
<Button type="submit" size="sm" loading={isSubmitting}>{editing ? 'Update' : 'Create'}</Button>
</Group>
</Stack>
</form>
</Paper>
);
}
export function QuestionPage() {
const { data: certRes } = useGetCertificationsQuery();
const { data, isLoading, isError } = useGetQuestionsQuery();
const [createQ, { isLoading: isCreating }] = useCreateQuestionMutation();
const [updateQ, { isLoading: isUpdating }] = useUpdateQuestionMutation();
const [deleteQ] = useDeleteQuestionMutation();
const certifications = certRes?.items ?? [];
const questions = data?.items ?? [];
const [editing, setEditing] = useState<Question | null>(null);
const [showForm, setShowForm] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Question | null>(null);
const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false);
const [certFilter, setCertFilter] = useState<string | null>(null);
const certOptions = certifications.filter((c) => c.isActive).map((c) => ({ value: c.id, label: c.name.en }));
const filtered = questions.filter((q) => !certFilter || q.certificationId === certFilter);
const getCertName = (id: string) => certifications.find((c) => c.id === id)?.name?.en ?? '-';
const resetForm = () => { setEditing(null); setShowForm(false); };
const handleSubmit = async (values: {
certificationId: string; titleEn: string; titleAm: string; form: string;
points: number; days: number; hours: number; minutes: number;
}, isEdit: boolean) => {
const title = { en: values.titleEn, am: values.titleAm };
const time = { days: values.days, hours: values.hours, minutes: values.minutes };
try {
if (isEdit && editing) {
await updateQ({ id: editing.id, certificationId: values.certificationId, title, form: values.form as QuestionForm, points: values.points, time }).unwrap();
notify.success('Question updated');
} else {
await createQ({ certificationId: values.certificationId, title, form: values.form as QuestionForm, points: values.points, time }).unwrap();
notify.success('Question created');
}
resetForm();
} catch {
notify.error('Operation failed');
}
};
const handleDelete = async () => {
if (!deleteTarget) return;
try {
await deleteQ(deleteTarget.id).unwrap();
notify.success('Question deleted');
closeDelete();
setDeleteTarget(null);
} catch {
notify.error('Failed to delete');
}
};
if (isLoading) return <Center py="xl"><Loader /></Center>;
if (isError) return <Alert icon={<IconInfoCircle size={16} />} color="red" title="Error loading questions" />;
return (
<Stack gap="lg">
<Group justify="space-between" align="flex-end">
<div>
<Title order={2}>Questions</Title>
<Text fz="sm" c="dimmed">Manage the question pool for examinations</Text>
</div>
{!showForm && (
<Button variant="light" leftSection={<IconPlus size={16} />} onClick={() => setShowForm(true)} size="sm">
Add Question
</Button>
)}
</Group>
{showForm && (
<QuestionForm
editing={editing}
certOptions={certOptions}
isSubmitting={isCreating || isUpdating}
onSubmit={handleSubmit}
onCancel={resetForm}
/>
)}
<Paper withBorder radius="md">
<Group p="md" justify="space-between" wrap="wrap" gap="sm">
<Text fw={600}>Question Pool</Text>
<Select placeholder="Filter by certification" data={[{ value: '', label: 'All' }, ...certOptions]} value={certFilter} onChange={(v) => setCertFilter(v ?? null)} size="sm" style={{ width: 280 }} clearable />
</Group>
<Table striped highlightOnHover>
<Table.Thead bg="var(--mantine-color-default-hover)">
<Table.Tr>
<Table.Th>Title (EN)</Table.Th>
<Table.Th>Certification</Table.Th>
<Table.Th>Form</Table.Th>
<Table.Th>Points</Table.Th>
<Table.Th>Status</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.en}</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'}>{q.form}</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 ? 'Active' : 'Inactive'}</Badge>
</Table.Td>
<Table.Td>
<Group gap="xs">
<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">No questions found</Text>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
</Paper>
<Modal opened={deleteOpened} onClose={closeDelete} title="Delete Question" size="sm">
<Text mb="md">Are you sure you want to delete this question?</Text>
<Group justify="flex-end">
<Button variant="default" onClick={closeDelete} size="sm">Cancel</Button>
<Button color="red" onClick={handleDelete} size="sm">Delete</Button>
</Group>
</Modal>
</Stack>
);
}

View File

@@ -0,0 +1,45 @@
import type { LocalePair } from '../../certification/types/certification';
export type QuestionForm = 'ESSAY' | 'CHOICE';
export interface EstimatedTime {
days: number;
hours: number;
minutes: number;
}
export interface Question {
id: string;
certificationId: string;
certification?: { id: string; name: LocalePair };
title: LocalePair;
form: QuestionForm;
time: EstimatedTime | null;
points: number;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export interface ListResponse<T> {
total: number;
items: T[];
}
export interface CreateQuestionPayload {
certificationId: string;
title: LocalePair;
form: QuestionForm;
time?: EstimatedTime;
points: number;
}
export interface UpdateQuestionPayload {
id: string;
certificationId?: string;
title?: LocalePair;
form?: QuestionForm;
time?: EstimatedTime;
points?: number;
isActive?: boolean;
}