mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
feat: added exam modules
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
import { baseApi } from '@ema-platform/api';
|
||||
import type {
|
||||
Certification,
|
||||
ListResponse,
|
||||
CreateCertificationPayload,
|
||||
UpdateCertificationPayload,
|
||||
} from '../types/certification';
|
||||
|
||||
const certificationApi = baseApi.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
getCertifications: builder.query<ListResponse<Certification>, void>({
|
||||
query: () => '/certifications',
|
||||
providesTags: ['Api'],
|
||||
}),
|
||||
getCertification: builder.query<Certification, string>({
|
||||
query: (id) => `/certifications/${id}`,
|
||||
providesTags: ['Api'],
|
||||
}),
|
||||
createCertification: builder.mutation<Certification, CreateCertificationPayload>({
|
||||
query: (body) => ({ url: '/certifications', method: 'POST', body }),
|
||||
invalidatesTags: ['Api'],
|
||||
}),
|
||||
updateCertification: builder.mutation<Certification, UpdateCertificationPayload>({
|
||||
query: ({ id, ...body }) => ({
|
||||
url: `/certifications/${id}`,
|
||||
method: 'PUT',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: ['Api'],
|
||||
}),
|
||||
deleteCertification: builder.mutation<void, string>({
|
||||
query: (id) => ({ url: `/certifications/${id}`, method: 'DELETE' }),
|
||||
invalidatesTags: ['Api'],
|
||||
}),
|
||||
}),
|
||||
overrideExisting: false,
|
||||
});
|
||||
|
||||
export const {
|
||||
useGetCertificationsQuery,
|
||||
useGetCertificationQuery,
|
||||
useCreateCertificationMutation,
|
||||
useUpdateCertificationMutation,
|
||||
useDeleteCertificationMutation,
|
||||
} = certificationApi;
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Stack,
|
||||
Title,
|
||||
Group,
|
||||
Button,
|
||||
Table,
|
||||
Badge,
|
||||
ActionIcon,
|
||||
Modal,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
Paper,
|
||||
Loader,
|
||||
Center,
|
||||
Alert,
|
||||
} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { IconEdit, IconTrash, IconPlus, IconInfoCircle, IconCertificate } from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import {
|
||||
useGetCertificationsQuery,
|
||||
useCreateCertificationMutation,
|
||||
useUpdateCertificationMutation,
|
||||
useDeleteCertificationMutation,
|
||||
} from '../api/certification-api';
|
||||
import type { Certification } from '../types/certification';
|
||||
|
||||
function CertificationForm({
|
||||
editing,
|
||||
isSubmitting,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: {
|
||||
editing: Certification | null;
|
||||
isSubmitting: boolean;
|
||||
onSubmit: (values: { nameEn: string; nameAm: string; descEn: string; descAm: string }, isEdit: boolean) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [nameEn, setNameEn] = useState(editing?.name?.en ?? '');
|
||||
const [nameAm, setNameAm] = useState(editing?.name?.am ?? '');
|
||||
const [descEn, setDescEn] = useState(editing?.description?.en ?? '');
|
||||
const [descAm, setDescAm] = useState(editing?.description?.am ?? '');
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!nameEn || !nameAm) {
|
||||
notify.error('Name fields are required');
|
||||
return;
|
||||
}
|
||||
onSubmit({ nameEn, nameAm, descEn, descAm }, !!editing);
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper p="md" withBorder mb="md" radius="md">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="sm">
|
||||
<TextInput label="Name (English)" placeholder="Certificate name in English" value={nameEn} onChange={(e) => setNameEn(e.currentTarget.value)} size="sm" required />
|
||||
<TextInput label="Name (Amharic)" placeholder="የምስክር ወረቀት ስም" value={nameAm} onChange={(e) => setNameAm(e.currentTarget.value)} size="sm" required />
|
||||
<Textarea label="Description (English)" placeholder="English description" value={descEn} onChange={(e) => setDescEn(e.currentTarget.value)} size="sm" autosize minRows={2} />
|
||||
<Textarea label="Description (Amharic)" placeholder="የአማርኛ መግለጫ" value={descAm} onChange={(e) => setDescAm(e.currentTarget.value)} size="sm" autosize minRows={2} />
|
||||
<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 CertificationPage() {
|
||||
const { data, isLoading, isError } = useGetCertificationsQuery();
|
||||
const [createCert, { isLoading: isCreating }] = useCreateCertificationMutation();
|
||||
const [updateCert, { isLoading: isUpdating }] = useUpdateCertificationMutation();
|
||||
const [deleteCert] = useDeleteCertificationMutation();
|
||||
|
||||
const certifications = data?.items ?? [];
|
||||
|
||||
const [editing, setEditing] = useState<Certification | null>(null);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Certification | null>(null);
|
||||
const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false);
|
||||
|
||||
const resetForm = () => {
|
||||
setEditing(null);
|
||||
setShowForm(false);
|
||||
};
|
||||
|
||||
const handleSubmit = async (values: { nameEn: string; nameAm: string; descEn: string; descAm: string }, isEdit: boolean) => {
|
||||
const name = { en: values.nameEn, am: values.nameAm };
|
||||
const description = { en: values.descEn, am: values.descAm };
|
||||
try {
|
||||
if (isEdit && editing) {
|
||||
await updateCert({ id: editing.id, name, description }).unwrap();
|
||||
notify.success('Certification updated');
|
||||
} else {
|
||||
await createCert({ name, description }).unwrap();
|
||||
notify.success('Certification created');
|
||||
}
|
||||
resetForm();
|
||||
} catch {
|
||||
notify.error('Operation failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
await deleteCert(deleteTarget.id).unwrap();
|
||||
notify.success('Certification 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 certifications" />;
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="flex-end">
|
||||
<div>
|
||||
<Title order={2}>Certifications</Title>
|
||||
<Text fz="sm" c="dimmed">Manage certification types (e.g. CoC, CoP)</Text>
|
||||
</div>
|
||||
{!showForm && (
|
||||
<Button variant="light" leftSection={<IconPlus size={16} />} onClick={() => setShowForm(true)} size="sm">
|
||||
Add Certification
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{showForm && (
|
||||
<CertificationForm
|
||||
editing={editing}
|
||||
isSubmitting={isCreating || isUpdating}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={resetForm}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Paper withBorder radius="md">
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
||||
<Table.Tr>
|
||||
<Table.Th>Name (EN)</Table.Th>
|
||||
<Table.Th>Name (AM)</Table.Th>
|
||||
<Table.Th>Description</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{certifications.map((cert) => (
|
||||
<Table.Tr key={cert.id}>
|
||||
<Table.Td><Text fz="sm" fw={500}>{cert.name.en}</Text></Table.Td>
|
||||
<Table.Td>{cert.name.am}</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz="sm" lineClamp={2} maw={250}>{cert.description.en || cert.description.am}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" variant="light" color={cert.isActive ? 'teal' : 'gray'}>
|
||||
{cert.isActive ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
<ActionIcon variant="subtle" color="blue" size="sm" onClick={() => { setEditing(cert); setShowForm(true); }}>
|
||||
<IconEdit size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => { setDeleteTarget(cert); openDelete(); }}>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
{certifications.length === 0 && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={5}>
|
||||
<Text c="dimmed" ta="center" py="xl">No certifications found</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
|
||||
<Modal opened={deleteOpened} onClose={closeDelete} title="Delete Certification" size="sm">
|
||||
<Text mb="md">Are you sure you want to delete <strong>{deleteTarget?.name?.en}</strong>?</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
export interface LocalePair {
|
||||
en: string;
|
||||
am: string;
|
||||
}
|
||||
|
||||
export interface Certification {
|
||||
id: string;
|
||||
name: LocalePair;
|
||||
description: LocalePair;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ListResponse<T> {
|
||||
total: number;
|
||||
items: T[];
|
||||
}
|
||||
|
||||
export interface CreateCertificationPayload {
|
||||
name: LocalePair;
|
||||
description: LocalePair;
|
||||
}
|
||||
|
||||
export interface UpdateCertificationPayload {
|
||||
id: string;
|
||||
name?: LocalePair;
|
||||
description?: LocalePair;
|
||||
isActive?: boolean;
|
||||
}
|
||||
55
apps/backoffice/src/app/features/exam/api/exam-api.ts
Normal file
55
apps/backoffice/src/app/features/exam/api/exam-api.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { baseApi } from '@ema-platform/api';
|
||||
import type {
|
||||
Exam,
|
||||
ListResponse,
|
||||
CreateExamPayload,
|
||||
UpdateExamPayload,
|
||||
AssignQuestionsPayload,
|
||||
} from '../types/exam';
|
||||
|
||||
const examApi = baseApi.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
getExams: builder.query<ListResponse<Exam>, void>({
|
||||
query: () => '/exams?q=i=questions',
|
||||
providesTags: ['Api'],
|
||||
}),
|
||||
getExam: builder.query<Exam, string>({
|
||||
query: (id) => `/exams/${id}?i=questions`,
|
||||
providesTags: ['Api'],
|
||||
}),
|
||||
createExam: builder.mutation<Exam, CreateExamPayload>({
|
||||
query: (body) => ({ url: '/exams', method: 'POST', body }),
|
||||
invalidatesTags: ['Api'],
|
||||
}),
|
||||
updateExam: builder.mutation<Exam, UpdateExamPayload>({
|
||||
query: ({ id, ...body }) => ({
|
||||
url: `/exams/${id}`,
|
||||
method: 'PUT',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: ['Api'],
|
||||
}),
|
||||
deleteExam: builder.mutation<void, string>({
|
||||
query: (id) => ({ url: `/exams/${id}`, method: 'DELETE' }),
|
||||
invalidatesTags: ['Api'],
|
||||
}),
|
||||
assignQuestions: builder.mutation<Exam, AssignQuestionsPayload>({
|
||||
query: ({ examId, questionIds, remark }) => ({
|
||||
url: `/exams/${examId}/questions`,
|
||||
method: 'POST',
|
||||
body: { examId, questionIds, remark },
|
||||
}),
|
||||
invalidatesTags: ['Api'],
|
||||
}),
|
||||
}),
|
||||
overrideExisting: false,
|
||||
});
|
||||
|
||||
export const {
|
||||
useGetExamsQuery,
|
||||
useGetExamQuery,
|
||||
useCreateExamMutation,
|
||||
useUpdateExamMutation,
|
||||
useDeleteExamMutation,
|
||||
useAssignQuestionsMutation,
|
||||
} = examApi;
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Paper,
|
||||
Group,
|
||||
Text,
|
||||
Stack,
|
||||
TextInput,
|
||||
Badge,
|
||||
ScrollArea,
|
||||
Checkbox,
|
||||
Box,
|
||||
Button,
|
||||
} from '@mantine/core';
|
||||
import { IconSearch } from '@tabler/icons-react';
|
||||
import type { QuestionBrief } from '../types/exam';
|
||||
|
||||
interface QuestionAssignerProps {
|
||||
available: QuestionBrief[];
|
||||
assigned: QuestionBrief[];
|
||||
onChange: (assigned: QuestionBrief[]) => void;
|
||||
}
|
||||
|
||||
function QuestionList({
|
||||
items,
|
||||
selected,
|
||||
onToggle,
|
||||
search,
|
||||
onSearchChange,
|
||||
label,
|
||||
}: {
|
||||
items: QuestionBrief[];
|
||||
selected: Set<string>;
|
||||
onToggle: (id: string) => void;
|
||||
search: string;
|
||||
onSearchChange: (v: string) => void;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fz="xs" fw={600} c="dimmed" mb={4}>{label} ({items.length})</Text>
|
||||
<Paper withBorder radius="md">
|
||||
<Group p="sm" pb={0}>
|
||||
<TextInput
|
||||
placeholder="Search..."
|
||||
leftSection={<IconSearch size={14} />}
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.currentTarget.value)}
|
||||
size="xs"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</Group>
|
||||
<ScrollArea h={280} p="sm" pt="xs">
|
||||
<Stack gap={4}>
|
||||
{items.length === 0 && (
|
||||
<Text fz="xs" c="dimmed" ta="center" py="xl">No questions</Text>
|
||||
)}
|
||||
{items.map((q) => (
|
||||
<Paper
|
||||
key={q.id}
|
||||
withBorder
|
||||
p="xs"
|
||||
radius="sm"
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
borderColor: selected.has(q.id) ? 'var(--mantine-color-blue-5)' : undefined,
|
||||
background: selected.has(q.id) ? 'var(--mantine-color-blue-0)' : undefined,
|
||||
}}
|
||||
onClick={() => onToggle(q.id)}
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Checkbox checked={selected.has(q.id)} onChange={() => onToggle(q.id)} size="xs" />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fz="xs" lineClamp={2}>{q.title.en}</Text>
|
||||
<Group gap={4} mt={2}>
|
||||
<Badge size="xs" variant="light" color={q.form === 'ESSAY' ? 'blue' : 'violet'}>{q.form}</Badge>
|
||||
<Badge size="xs" variant="light" color="gray">{q.points} pts</Badge>
|
||||
</Group>
|
||||
</div>
|
||||
</Group>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function QuestionAssigner({ available, assigned, onChange }: QuestionAssignerProps) {
|
||||
const [searchLeft, setSearchLeft] = useState('');
|
||||
const [searchRight, setSearchRight] = useState('');
|
||||
const [selectedLeft, setSelectedLeft] = useState<Set<string>>(new Set());
|
||||
const [selectedRight, setSelectedRight] = useState<Set<string>>(new Set());
|
||||
|
||||
const assignedIds = new Set(assigned.map((q) => q.id));
|
||||
|
||||
const filteredAvailable = available.filter(
|
||||
(q) => !assignedIds.has(q.id) && (q.title.en.toLowerCase().includes(searchLeft.toLowerCase()) || q.title.am.includes(searchLeft))
|
||||
);
|
||||
const filteredAssigned = assigned.filter(
|
||||
(q) => q.title.en.toLowerCase().includes(searchRight.toLowerCase()) || q.title.am.includes(searchRight)
|
||||
);
|
||||
|
||||
const assignSelected = () => {
|
||||
const toAssign = available.filter((q) => selectedLeft.has(q.id));
|
||||
onChange([...assigned, ...toAssign]);
|
||||
setSelectedLeft(new Set());
|
||||
};
|
||||
|
||||
const removeSelected = () => {
|
||||
onChange(assigned.filter((q) => !selectedRight.has(q.id)));
|
||||
setSelectedRight(new Set());
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Text fz="sm" fw={500}>Assign Questions to Exam</Text>
|
||||
<Group gap="sm" align="stretch" wrap="nowrap">
|
||||
<QuestionList
|
||||
items={filteredAvailable}
|
||||
selected={selectedLeft}
|
||||
onToggle={(id) => {
|
||||
const next = new Set(selectedLeft);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
setSelectedLeft(next);
|
||||
}}
|
||||
search={searchLeft}
|
||||
onSearchChange={setSearchLeft}
|
||||
label="Available Questions"
|
||||
/>
|
||||
<QuestionList
|
||||
items={filteredAssigned}
|
||||
selected={selectedRight}
|
||||
onToggle={(id) => {
|
||||
const next = new Set(selectedRight);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
setSelectedRight(next);
|
||||
}}
|
||||
search={searchRight}
|
||||
onSearchChange={setSearchRight}
|
||||
label="Assigned Questions"
|
||||
/>
|
||||
</Group>
|
||||
<Group gap="sm" justify="center">
|
||||
{selectedLeft.size > 0 && (
|
||||
<Button size="xs" variant="light" onClick={assignSelected}>
|
||||
Assign Selected ({selectedLeft.size})
|
||||
</Button>
|
||||
)}
|
||||
{selectedRight.size > 0 && (
|
||||
<Button size="xs" variant="light" color="red" onClick={removeSelected}>
|
||||
Remove Selected ({selectedRight.size})
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
381
apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx
Normal file
381
apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx
Normal file
@@ -0,0 +1,381 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import {
|
||||
Stack,
|
||||
Title,
|
||||
Group,
|
||||
Text,
|
||||
Paper,
|
||||
Badge,
|
||||
SimpleGrid,
|
||||
Divider,
|
||||
Button,
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Loader,
|
||||
Center,
|
||||
Modal,
|
||||
Table,
|
||||
Select,
|
||||
NumberInput,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Box,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import {
|
||||
IconArrowLeft,
|
||||
IconPrinter,
|
||||
IconPlus,
|
||||
IconInfoCircle,
|
||||
IconCertificate,
|
||||
IconCalendar,
|
||||
IconMapPin,
|
||||
IconClock,
|
||||
IconScoreboard,
|
||||
IconUser,
|
||||
IconCheck,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { useApiQuery, useApiMutation } from '@ema-platform/api';
|
||||
import { useGetExamQuery, useUpdateExamMutation } from '../api/exam-api';
|
||||
import type { Exam, ExamStatus } from '../types/exam';
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
PENDING: 'gray', ACTIVE: 'blue', COMPLETED: 'teal',
|
||||
CANCELLED: 'red', POSTPONED: 'orange', PUBLISHED: 'green',
|
||||
};
|
||||
|
||||
const FORM_LABEL: Record<string, string> = { ESSAY: 'Essay', CHOICE: 'Choice' };
|
||||
const TYPE_LABEL: Record<string, string> = { WRITTEN: 'Written', ORAL: 'Oral' };
|
||||
const ADMIN_LABEL: Record<string, string> = { OFFLINE: 'Offline', ONLINE: 'Online' };
|
||||
const EVAL_LABEL: Record<string, string> = { SUM: 'Sum', AVERAGE: 'Average', PERCENTAGE: 'Percentage' };
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>{label}</Text>
|
||||
<Text fz="sm" fw={500}>{value || '—'}</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RecordResultModal({
|
||||
exam,
|
||||
opened,
|
||||
onClose,
|
||||
}: {
|
||||
exam: Exam;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [seafarerSearch, setSeafarerSearch] = useState('');
|
||||
const [selectedSeafarerId, setSelectedSeafarerId] = useState<string | null>(null);
|
||||
const [scores, setScores] = useState<Record<string, number>>({});
|
||||
const [remark, setRemark] = useState('');
|
||||
|
||||
const { data: profilesRes } = useApiQuery<{ total: number; items: any[] }>({
|
||||
url: '/profiles',
|
||||
params: { q: 'w=type:=:SEAFARER' },
|
||||
});
|
||||
const [createResult, { isLoading: isSaving }] = useApiMutation();
|
||||
|
||||
const seafarers = profilesRes?.items ?? [];
|
||||
const questions = exam.questions ?? [];
|
||||
|
||||
const seafarerOptions = seafarers.map((s: any) => ({
|
||||
value: s.id,
|
||||
label: `${s.firstName} ${s.middleName ?? ''} ${s.lastName}`,
|
||||
}));
|
||||
|
||||
const filteredOptions = seafarerSearch
|
||||
? seafarerOptions.filter((o: any) => o.label.toLowerCase().includes(seafarerSearch.toLowerCase()))
|
||||
: seafarerOptions;
|
||||
|
||||
const totalScore = questions.reduce((sum, q) => sum + (scores[q.id] ?? 0), 0);
|
||||
const passed = totalScore >= exam.cuttingPoint;
|
||||
|
||||
const handleScoreChange = (questionId: string, value: number) => {
|
||||
setScores((prev) => ({ ...prev, [questionId]: value }));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!selectedSeafarerId) {
|
||||
notify.error('Please select a seafarer');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const breakdowns = questions.map((q) => ({
|
||||
questionId: q.id,
|
||||
score: scores[q.id] ?? 0,
|
||||
remark: '',
|
||||
}));
|
||||
await createResult({
|
||||
url: '/results',
|
||||
method: 'POST',
|
||||
body: {
|
||||
seafarerId: selectedSeafarerId,
|
||||
examId: exam.id,
|
||||
resultBreakdowns: breakdowns,
|
||||
totalScore,
|
||||
remark: remark ? { en: remark, am: '' } : undefined,
|
||||
},
|
||||
}).unwrap();
|
||||
notify.success(`Result recorded — ${passed ? 'PASSED' : 'FAILED'} (${totalScore}/${exam.cuttingPoint})`);
|
||||
setSelectedSeafarerId(null);
|
||||
setScores({});
|
||||
setRemark('');
|
||||
setSeafarerSearch('');
|
||||
onClose();
|
||||
} catch {
|
||||
notify.error('Failed to save result');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title={`Record Result — ${exam.title.en}`} size="lg" radius="lg">
|
||||
<Stack gap="md">
|
||||
<Select
|
||||
label="Seafarer"
|
||||
placeholder="Search and select a seafarer"
|
||||
data={filteredOptions}
|
||||
value={selectedSeafarerId}
|
||||
onChange={(v) => {
|
||||
setSelectedSeafarerId(v);
|
||||
setScores({});
|
||||
}}
|
||||
searchable
|
||||
onSearchChange={setSeafarerSearch}
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
|
||||
{selectedSeafarerId && questions.length > 0 && (
|
||||
<>
|
||||
<Divider label="Score per Question" labelPosition="center" />
|
||||
<Table striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Question</Table.Th>
|
||||
<Table.Th>Form</Table.Th>
|
||||
<Table.Th>Max Points</Table.Th>
|
||||
<Table.Th>Score</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{questions.map((q) => (
|
||||
<Table.Tr key={q.id}>
|
||||
<Table.Td><Text fz="sm" maw={250} lineClamp={2}>{q.title.en}</Text></Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="xs" 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>
|
||||
<NumberInput
|
||||
value={scores[q.id] ?? 0}
|
||||
onChange={(v) => handleScoreChange(q.id, Number(v))}
|
||||
min={0}
|
||||
max={q.points}
|
||||
size="xs"
|
||||
style={{ width: 80 }}
|
||||
/>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
|
||||
<Paper withBorder p="sm" radius="md" bg="gray.0">
|
||||
<SimpleGrid cols={3} spacing="sm">
|
||||
<InfoRow label="Total Score" value={String(totalScore)} />
|
||||
<InfoRow label="Pass Mark" value={String(exam.cuttingPoint)} />
|
||||
<div>
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Status</Text>
|
||||
<Group gap={4} mt={2}>
|
||||
{passed
|
||||
? <><IconCheck size={14} color="var(--mantine-color-teal-6)" /><Text fz="sm" fw={700} c="teal">PASSED</Text></>
|
||||
: <><IconX size={14} color="var(--mantine-color-red-6)" /><Text fz="sm" fw={700} c="red">FAILED</Text></>}
|
||||
</Group>
|
||||
</div>
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
<TextInput
|
||||
label="Remark (optional)"
|
||||
placeholder="Officer remarks"
|
||||
value={remark}
|
||||
onChange={(e) => setRemark(e.currentTarget.value)}
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose} size="sm">Cancel</Button>
|
||||
<Button onClick={handleSave} size="sm" loading={isSaving}>
|
||||
Save Result
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedSeafarerId && questions.length === 0 && (
|
||||
<Alert color="yellow" icon={<IconInfoCircle size={15} />}>
|
||||
No questions assigned to this exam. Assign questions first.
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExamDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const printRef = useRef<HTMLDivElement>(null);
|
||||
const [recordOpened, { open: openRecord, close: closeRecord }] = useDisclosure(false);
|
||||
const [updateExam] = useUpdateExamMutation();
|
||||
|
||||
const { data: exam, isLoading, isError } = useGetExamQuery(id ?? '', { skip: !id });
|
||||
|
||||
if (isLoading) return <Center py="xl"><Loader /></Center>;
|
||||
if (isError || !exam) {
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Button variant="subtle" leftSection={<IconArrowLeft size={15} />} w="fit-content" onClick={() => navigate('/exams')}>Back to Exams</Button>
|
||||
<Alert color="red" icon={<IconInfoCircle size={17} />}>Exam not found.</Alert>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const handlePrint = () => {
|
||||
const printWindow = window.open('', '_blank');
|
||||
if (!printWindow) return;
|
||||
const qHtml = (exam.questions ?? []).map((q, i) => `
|
||||
<div style="margin-bottom: 24px; page-break-inside: avoid;">
|
||||
<p style="font-weight: 700; margin-bottom: 4px; font-size: 13px;">Question ${i + 1} (${q.points} pts — ${FORM_LABEL[q.form] ?? q.form})</p>
|
||||
<p style="margin: 0 0 8px 0; font-size: 14px; line-height: 1.5;">${q.title.en}</p>
|
||||
${q.form === 'ESSAY' ? '<div style="border-bottom: 1px dashed #ccc; height: 80px; margin-bottom: 12px;"></div>'.repeat(3) : ''}
|
||||
${q.form === 'CHOICE' ? ['A. ______', 'B. ______', 'C. ______', 'D. ______'].map(l => `<p style="margin: 4px 0; font-size: 13px;">${l}</p>`).join('') : ''}
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
printWindow.document.write(`
|
||||
<html><head><title>${exam.title.en}</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; padding: 40px; max-width: 800px; margin: auto; }
|
||||
.header { text-align: center; margin-bottom: 32px; border-bottom: 2px solid #333; padding-bottom: 16px; }
|
||||
.header h1 { font-size: 20px; margin: 0 0 4px; }
|
||||
.header p { margin: 2px 0; font-size: 13px; color: #555; }
|
||||
.directions { background: #f5f5f5; padding: 12px 16px; border-radius: 4px; margin-bottom: 24px; font-size: 13px; }
|
||||
.directions strong { display: block; margin-bottom: 4px; }
|
||||
@media print { @page { margin: 20mm; } body { -webkit-print-color-adjust: exact; } }
|
||||
</style></head><body>
|
||||
<div class="header">
|
||||
<h1>${exam.title.en}</h1>
|
||||
<p>${exam.title.am}</p>
|
||||
<p>Date: ${exam.date} | Venue: ${exam.venue}</p>
|
||||
<p>Form: ${FORM_LABEL[exam.form]} | Type: ${TYPE_LABEL[exam.type]} | Time Allowed: ${exam.givenTime ? `${exam.givenTime.days}d ${exam.givenTime.hours}h ${exam.givenTime.minutes}m` : 'N/A'}</p>
|
||||
<p>Pass Mark: ${exam.cuttingPoint} | Total Questions: ${(exam.questions ?? []).length}</p>
|
||||
</div>
|
||||
${exam.direction?.en ? `<div class="directions"><strong>Directions:</strong>${exam.direction.en}</div>` : ''}
|
||||
${qHtml}
|
||||
<div style="margin-top: 40px; border-top: 1px solid #ccc; padding-top: 12px; font-size: 12px; color: #888; text-align: center;">
|
||||
Generated by EMA — Ethiopian Maritime Authority
|
||||
</div>
|
||||
</body></html>
|
||||
`);
|
||||
printWindow.document.close();
|
||||
printWindow.focus();
|
||||
setTimeout(() => printWindow.print(), 500);
|
||||
};
|
||||
|
||||
const totalPoints = (exam.questions ?? []).reduce((s, q) => s + q.points, 0);
|
||||
|
||||
return (
|
||||
<Stack gap="md" ref={printRef}>
|
||||
{/* Header */}
|
||||
<Group justify="space-between">
|
||||
<Group gap="sm">
|
||||
<ActionIcon variant="subtle" size="lg" onClick={() => navigate('/exams')}>
|
||||
<IconArrowLeft size={18} />
|
||||
</ActionIcon>
|
||||
<div>
|
||||
<Title order={3}>{exam.title.en}</Title>
|
||||
<Group gap={6} mt={2}>
|
||||
<Text fz="sm" c="dimmed">{exam.title.am}</Text>
|
||||
<Text fz="sm" c="dimmed">·</Text>
|
||||
<Text fz="sm" c="dimmed">{exam.date}</Text>
|
||||
</Group>
|
||||
</div>
|
||||
</Group>
|
||||
<Group gap="sm">
|
||||
<Button variant="light" leftSection={<IconPrinter size={15} />} onClick={handlePrint} size="sm">
|
||||
Print Exam
|
||||
</Button>
|
||||
<Button leftSection={<IconPlus size={15} />} onClick={openRecord} size="sm">
|
||||
Record Result
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Status badge */}
|
||||
<Badge size="lg" variant="light" color={STATUS_COLOR[exam.status]} style={{ width: 'fit-content' }}>
|
||||
{exam.status}
|
||||
</Badge>
|
||||
|
||||
{/* Exam Info */}
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Title order={5} mb="md">Exam Details</Title>
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
|
||||
<InfoRow label="Certification" value={exam.certification?.name?.en ?? exam.certificationId} />
|
||||
<InfoRow label="Type" value={TYPE_LABEL[exam.type] ?? exam.type} />
|
||||
<InfoRow label="Form" value={FORM_LABEL[exam.form] ?? exam.form} />
|
||||
<InfoRow label="Venue" value={exam.venue} />
|
||||
<InfoRow label="Date" value={exam.date} />
|
||||
<InfoRow label="Administration" value={ADMIN_LABEL[exam.administrationMethod] ?? exam.administrationMethod} />
|
||||
<InfoRow label="Evaluation" value={EVAL_LABEL[exam.evaluationMethod] ?? exam.evaluationMethod} />
|
||||
<InfoRow label="Selection" value={exam.selectionMethod} />
|
||||
<InfoRow label="Time Allowed" value={exam.givenTime ? `${exam.givenTime.days}d ${exam.givenTime.hours}h ${exam.givenTime.minutes}m` : '—'} />
|
||||
<InfoRow label="Pass Mark" value={String(exam.cuttingPoint)} />
|
||||
<InfoRow label="Total Points" value={String(totalPoints)} />
|
||||
<InfoRow label="Questions" value={String((exam.questions ?? []).length)} />
|
||||
</SimpleGrid>
|
||||
{exam.direction?.en && (
|
||||
<>
|
||||
<Divider my="md" />
|
||||
<InfoRow label="Directions" value={exam.direction.en} />
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{/* Questions */}
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Title order={5} mb="md">Questions ({totalPoints} pts total)</Title>
|
||||
{(exam.questions ?? []).length === 0 ? (
|
||||
<Alert color="gray" icon={<IconInfoCircle size={16} />}>
|
||||
No questions assigned yet. Use the exam list to assign questions.
|
||||
</Alert>
|
||||
) : (
|
||||
<Stack gap="md">
|
||||
{(exam.questions ?? []).map((q, i) => (
|
||||
<Paper key={q.id} withBorder p="md" radius="md">
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Text fz="sm" fw={700}>Question {i + 1}</Text>
|
||||
<Group gap={4}>
|
||||
<Badge size="xs" variant="light" color={q.form === 'ESSAY' ? 'blue' : 'violet'}>{FORM_LABEL[q.form] ?? q.form}</Badge>
|
||||
<Badge size="xs" variant="light" color="gray">{q.points} pts</Badge>
|
||||
</Group>
|
||||
</Group>
|
||||
<Text fz="sm">{q.title.en}</Text>
|
||||
{q.title.am && <Text fz="xs" c="dimmed" mt={2}>{q.title.am}</Text>}
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
<RecordResultModal exam={exam} opened={recordOpened} onClose={closeRecord} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
379
apps/backoffice/src/app/features/exam/pages/ExamPage.tsx
Normal file
379
apps/backoffice/src/app/features/exam/pages/ExamPage.tsx
Normal file
@@ -0,0 +1,379 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Stack,
|
||||
Title,
|
||||
Group,
|
||||
Button,
|
||||
Table,
|
||||
Badge,
|
||||
ActionIcon,
|
||||
Modal,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
Paper,
|
||||
Loader,
|
||||
Center,
|
||||
Alert,
|
||||
Select,
|
||||
NumberInput,
|
||||
Tabs,
|
||||
SimpleGrid,
|
||||
Divider,
|
||||
} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { IconEdit, IconTrash, IconPlus, IconInfoCircle, IconCalendar, IconClipboardList } from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { useGetCertificationsQuery } from '../../certification/api/certification-api';
|
||||
import { useGetQuestionsQuery } from '../../question/api/question-api';
|
||||
import {
|
||||
useGetExamsQuery,
|
||||
useCreateExamMutation,
|
||||
useUpdateExamMutation,
|
||||
useDeleteExamMutation,
|
||||
useAssignQuestionsMutation,
|
||||
} from '../api/exam-api';
|
||||
import type { Exam } from '../types/exam';
|
||||
import { QuestionAssigner } from '../components/QuestionAssigner';
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
PENDING: 'gray',
|
||||
ACTIVE: 'blue',
|
||||
COMPLETED: 'teal',
|
||||
CANCELLED: 'red',
|
||||
POSTPONED: 'orange',
|
||||
PUBLISHED: 'green',
|
||||
};
|
||||
|
||||
function ExamForm({
|
||||
editing,
|
||||
certOptions,
|
||||
isSubmitting,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: {
|
||||
editing: Exam | null;
|
||||
certOptions: { value: string; label: string }[];
|
||||
isSubmitting: boolean;
|
||||
onSubmit: (values: any, 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 [directionEn, setDirectionEn] = useState(editing?.direction?.en ?? '');
|
||||
const [directionAm, setDirectionAm] = useState(editing?.direction?.am ?? '');
|
||||
const [date, setDate] = useState(editing?.date ?? '');
|
||||
const [days, setDays] = useState(editing?.givenTime?.days ?? 0);
|
||||
const [hours, setHours] = useState(editing?.givenTime?.hours ?? 0);
|
||||
const [minutes, setMinutes] = useState(editing?.givenTime?.minutes ?? 0);
|
||||
const [type, setType] = useState<string | null>(editing?.type ?? null);
|
||||
const [form, setForm] = useState<string | null>(editing?.form ?? null);
|
||||
const [venue, setVenue] = useState(editing?.venue ?? '');
|
||||
const [adminMethod, setAdminMethod] = useState<string | null>(editing?.administrationMethod ?? null);
|
||||
const [evalMethod, setEvalMethod] = useState<string | null>(editing?.evaluationMethod ?? null);
|
||||
const [selMethod, setSelMethod] = useState<string | null>(editing?.selectionMethod ?? null);
|
||||
const [cuttingPoint, setCuttingPoint] = useState<number>(editing?.cuttingPoint ?? 0);
|
||||
const [status, setStatus] = useState<string | null>(editing?.status ?? null);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!certificationId || !titleEn || !titleAm || !date || !type || !form || !venue || !adminMethod || !evalMethod) {
|
||||
notify.error('Please fill all required fields');
|
||||
return;
|
||||
}
|
||||
onSubmit({
|
||||
certificationId, titleEn, titleAm, directionEn, directionAm,
|
||||
date, days, hours, minutes, type, form, venue, adminMethod, evalMethod, selMethod, cuttingPoint, status,
|
||||
}, !!editing);
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper p="md" withBorder mb="md" radius="md">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Tabs defaultValue="basic" variant="outline" radius="md">
|
||||
<Tabs.List mb="md">
|
||||
<Tabs.Tab value="basic" leftSection={<IconInfoCircle size={15} />}>Basic Info</Tabs.Tab>
|
||||
<Tabs.Tab value="settings" leftSection={<IconClipboardList size={15} />}>Settings</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="basic">
|
||||
<Stack gap="sm">
|
||||
<Select label="Certification" placeholder="Select certification" data={certOptions} value={certificationId} onChange={setCertificationId} size="sm" searchable required />
|
||||
<TextInput label="Title (English)" placeholder="Exam title 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 />
|
||||
<Textarea label="Direction (English)" placeholder="Instructions in English" value={directionEn} onChange={(e) => setDirectionEn(e.currentTarget.value)} size="sm" autosize minRows={2} />
|
||||
<Textarea label="Direction (Amharic)" placeholder="መመሪያ በአማርኛ" value={directionAm} onChange={(e) => setDirectionAm(e.currentTarget.value)} size="sm" autosize minRows={2} />
|
||||
<TextInput label="Exam Date" type="date" value={date} onChange={(e) => setDate(e.currentTarget.value)} size="sm" leftSection={<IconCalendar size={14} />} required />
|
||||
<TextInput label="Venue" placeholder="Exam venue" value={venue} onChange={(e) => setVenue(e.currentTarget.value)} 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>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="settings">
|
||||
<Stack gap="sm">
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<Select label="Type" placeholder="Written or Oral" data={[{ value: 'WRITTEN', label: 'Written' }, { value: 'ORAL', label: 'Oral' }]} value={type} onChange={setType} size="sm" required />
|
||||
<Select label="Form" placeholder="Essay or Choice" data={[{ value: 'ESSAY', label: 'Essay' }, { value: 'CHOICE', label: 'Choice' }]} value={form} onChange={setForm} size="sm" required />
|
||||
<Select label="Administration" placeholder="Offline or Online" data={[{ value: 'OFFLINE', label: 'Offline' }, { value: 'ONLINE', label: 'Online' }]} value={adminMethod} onChange={setAdminMethod} size="sm" required />
|
||||
<Select label="Evaluation Method" placeholder="How to compute score" data={[{ value: 'SUM', label: 'Sum' }, { value: 'AVERAGE', label: 'Average' }, { value: 'PERCENTAGE', label: 'Percentage' }]} value={evalMethod} onChange={setEvalMethod} size="sm" required />
|
||||
<Select label="Selection Method" placeholder="Manual or Random" data={[{ value: 'MANUAL', label: 'Manual' }, { value: 'RANDOM', label: 'Random' }]} value={selMethod} onChange={setSelMethod} size="sm" />
|
||||
<NumberInput label="Cutting Point (Pass Mark)" placeholder="Minimum score to pass" value={cuttingPoint} onChange={(v) => setCuttingPoint(Number(v))} min={0} size="sm" required />
|
||||
</SimpleGrid>
|
||||
{editing && (
|
||||
<Select label="Status" placeholder="Exam status" data={[
|
||||
{ value: 'PENDING', label: 'Pending' }, { value: 'ACTIVE', label: 'Active' },
|
||||
{ value: 'COMPLETED', label: 'Completed' }, { value: 'CANCELLED', label: 'Cancelled' },
|
||||
{ value: 'POSTPONED', label: 'Postponed' }, { value: 'PUBLISHED', label: 'Published' },
|
||||
]} value={status} onChange={setStatus} size="sm" />
|
||||
)}
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button variant="default" onClick={onCancel} size="sm">Cancel</Button>
|
||||
<Button type="submit" size="sm" loading={isSubmitting}>{editing ? 'Update Exam' : 'Create Exam'}</Button>
|
||||
</Group>
|
||||
</form>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExamPage() {
|
||||
const navigate = useNavigate();
|
||||
const { data: certRes } = useGetCertificationsQuery();
|
||||
const { data: qRes } = useGetQuestionsQuery();
|
||||
const { data, isLoading, isError } = useGetExamsQuery();
|
||||
const [createExam, { isLoading: isCreating }] = useCreateExamMutation();
|
||||
const [updateExam, { isLoading: isUpdating }] = useUpdateExamMutation();
|
||||
const [deleteExam] = useDeleteExamMutation();
|
||||
const [assignQuestions] = useAssignQuestionsMutation();
|
||||
|
||||
const certifications = certRes?.items ?? [];
|
||||
const allQuestions = qRes?.items ?? [];
|
||||
const exams = data?.items ?? [];
|
||||
|
||||
const [editing, setEditing] = useState<Exam | null>(null);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Exam | null>(null);
|
||||
const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false);
|
||||
const [assignTarget, setAssignTarget] = useState<Exam | null>(null);
|
||||
const [assignOpened, { open: openAssign, close: closeAssign }] = useDisclosure(false);
|
||||
const [randomCount, setRandomCount] = useState(5);
|
||||
|
||||
const certOptions = certifications.filter((c) => c.isActive).map((c) => ({ value: c.id, label: c.name.en }));
|
||||
const getCertName = (id: string) => certifications.find((c) => c.id === id)?.name?.en ?? '-';
|
||||
|
||||
const resetForm = () => { setEditing(null); setShowForm(false); };
|
||||
|
||||
const handleSubmit = async (values: any, isEdit: boolean) => {
|
||||
const payload: any = {
|
||||
certificationId: values.certificationId,
|
||||
title: { en: values.titleEn, am: values.titleAm },
|
||||
direction: values.directionEn || values.directionAm ? { en: values.directionEn, am: values.directionAm } : undefined,
|
||||
date: values.date,
|
||||
givenTime: { days: values.days, hours: values.hours, minutes: values.minutes },
|
||||
type: values.type,
|
||||
form: values.form,
|
||||
venue: values.venue,
|
||||
administrationMethod: values.adminMethod,
|
||||
evaluationMethod: values.evalMethod,
|
||||
selectionMethod: values.selMethod || 'MANUAL',
|
||||
cuttingPoint: values.cuttingPoint,
|
||||
};
|
||||
if (isEdit) payload.status = values.status;
|
||||
|
||||
try {
|
||||
if (isEdit && editing) {
|
||||
await updateExam({ id: editing.id, ...payload }).unwrap();
|
||||
notify.success('Exam updated');
|
||||
} else {
|
||||
await createExam(payload).unwrap();
|
||||
notify.success('Exam created');
|
||||
}
|
||||
resetForm();
|
||||
} catch {
|
||||
notify.error('Operation failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
await deleteExam(deleteTarget.id).unwrap();
|
||||
notify.success('Exam deleted');
|
||||
closeDelete();
|
||||
setDeleteTarget(null);
|
||||
} catch {
|
||||
notify.error('Failed to delete');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRandomSelect = () => {
|
||||
if (!assignTarget) return;
|
||||
const assignedIds = new Set((assignTarget.questions ?? []).map((q) => q.id));
|
||||
const unassigned = allQuestions
|
||||
.filter((q) => !assignedIds.has(q.id))
|
||||
.map((q) => ({ id: q.id, title: q.title, form: q.form, points: q.points }));
|
||||
const shuffled = [...unassigned].sort(() => Math.random() - 0.5);
|
||||
const picked = shuffled.slice(0, Math.min(randomCount, shuffled.length));
|
||||
setAssignTarget({
|
||||
...assignTarget,
|
||||
questions: [...(assignTarget.questions ?? []), ...picked],
|
||||
});
|
||||
notify.info(`Randomly selected ${picked.length} questions`);
|
||||
};
|
||||
|
||||
const handleAssign = async () => {
|
||||
if (!assignTarget) return;
|
||||
try {
|
||||
const questionIds = assignTarget.questions?.map((q) => q.id) ?? [];
|
||||
await assignQuestions({ examId: assignTarget.id, questionIds, remark: undefined }).unwrap();
|
||||
notify.success('Questions assigned');
|
||||
closeAssign();
|
||||
setAssignTarget(null);
|
||||
} catch {
|
||||
notify.error('Failed to assign questions');
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) return <Center py="xl"><Loader /></Center>;
|
||||
if (isError) return <Alert icon={<IconInfoCircle size={16} />} color="red" title="Error loading exams" />;
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="flex-end">
|
||||
<div>
|
||||
<Title order={2}>Examinations</Title>
|
||||
<Text fz="sm" c="dimmed">Manage exams, assign questions, and track results</Text>
|
||||
</div>
|
||||
{!showForm && (
|
||||
<Button variant="light" leftSection={<IconPlus size={16} />} onClick={() => setShowForm(true)} size="sm">
|
||||
Create Exam
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{showForm && (
|
||||
<ExamForm
|
||||
editing={editing}
|
||||
certOptions={certOptions}
|
||||
isSubmitting={isCreating || isUpdating}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={resetForm}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Paper withBorder radius="md">
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
||||
<Table.Tr>
|
||||
<Table.Th>Title</Table.Th>
|
||||
<Table.Th>Certification</Table.Th>
|
||||
<Table.Th>Date</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Form</Table.Th>
|
||||
<Table.Th>Venue</Table.Th>
|
||||
<Table.Th>Questions</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{exams.map((exam) => (
|
||||
<Table.Tr key={exam.id}>
|
||||
<Table.Td>
|
||||
<Text fz="sm" fw={500} c="blue" style={{ cursor: 'pointer' }} onClick={() => navigate(`/exams/${exam.id}`)}>
|
||||
{exam.title.en}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td><Text fz="sm">{getCertName(exam.certificationId)}</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm">{exam.date}</Text></Table.Td>
|
||||
<Table.Td><Badge size="sm" variant="light" color={exam.type === 'WRITTEN' ? 'blue' : 'orange'}>{exam.type}</Badge></Table.Td>
|
||||
<Table.Td><Badge size="sm" variant="light" color={exam.form === 'ESSAY' ? 'blue' : 'violet'}>{exam.form}</Badge></Table.Td>
|
||||
<Table.Td><Text fz="sm">{exam.venue}</Text></Table.Td>
|
||||
<Table.Td>
|
||||
<Button size="xs" variant="subtle" onClick={() => { setAssignTarget(exam); openAssign(); }}>
|
||||
{exam.questions?.length ?? 0} questions
|
||||
</Button>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" variant="light" color={STATUS_COLOR[exam.status]}>{exam.status}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
<ActionIcon variant="subtle" color="blue" size="sm" onClick={() => { setEditing(exam); setShowForm(true); }}>
|
||||
<IconEdit size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => { setDeleteTarget(exam); openDelete(); }}>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
{exams.length === 0 && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={9}>
|
||||
<Text c="dimmed" ta="center" py="xl">No exams found</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
|
||||
{/* Delete confirmation */}
|
||||
<Modal opened={deleteOpened} onClose={closeDelete} title="Delete Exam" size="sm">
|
||||
<Text mb="md">Are you sure you want to delete <strong>{deleteTarget?.title?.en}</strong>?</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>
|
||||
|
||||
{/* Question assignment modal */}
|
||||
<Modal opened={assignOpened} onClose={closeAssign} title={`Assign Questions — ${assignTarget?.title?.en ?? ''}`} size="xl" radius="lg">
|
||||
{assignTarget && (
|
||||
<Stack gap="md">
|
||||
<QuestionAssigner
|
||||
available={allQuestions.map((q) => ({ id: q.id, title: q.title, form: q.form, points: q.points }))}
|
||||
assigned={assignTarget.questions ?? []}
|
||||
onChange={(updated) => {
|
||||
setAssignTarget({ ...assignTarget, questions: updated });
|
||||
}}
|
||||
/>
|
||||
<Group justify="space-between">
|
||||
<Group gap="sm">
|
||||
<NumberInput
|
||||
placeholder="Count"
|
||||
value={randomCount}
|
||||
onChange={(v) => setRandomCount(Number(v))}
|
||||
min={1}
|
||||
max={allQuestions.length}
|
||||
size="xs"
|
||||
style={{ width: 80 }}
|
||||
/>
|
||||
<Button size="xs" variant="light" onClick={handleRandomSelect}>
|
||||
Randomly Select
|
||||
</Button>
|
||||
</Group>
|
||||
<Group gap="sm">
|
||||
<Button variant="default" onClick={closeAssign} size="sm">Cancel</Button>
|
||||
<Button onClick={handleAssign} size="sm">Save Assignments</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
81
apps/backoffice/src/app/features/exam/types/exam.ts
Normal file
81
apps/backoffice/src/app/features/exam/types/exam.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import type { LocalePair } from '../../certification/types/certification';
|
||||
import type { EstimatedTime } from '../../question/types/question';
|
||||
import type { QuestionForm } from '../../question/types/question';
|
||||
export type { QuestionForm };
|
||||
|
||||
export type ExamType = 'WRITTEN' | 'ORAL';
|
||||
export type ExamAdministrationMethod = 'OFFLINE' | 'ONLINE';
|
||||
export type ExamEvaluationMethod = 'SUM' | 'AVERAGE' | 'PERCENTAGE';
|
||||
export type ExamSelectionMethod = 'MANUAL' | 'RANDOM';
|
||||
export type ExamStatus = 'PENDING' | 'ACTIVE' | 'COMPLETED' | 'CANCELLED' | 'POSTPONED' | 'PUBLISHED';
|
||||
|
||||
export interface QuestionBrief {
|
||||
id: string;
|
||||
title: LocalePair;
|
||||
form: QuestionForm;
|
||||
points: number;
|
||||
}
|
||||
|
||||
export interface Exam {
|
||||
id: string;
|
||||
certificationId: string;
|
||||
certification?: { id: string; name: LocalePair };
|
||||
title: LocalePair;
|
||||
direction: LocalePair | null;
|
||||
date: string;
|
||||
givenTime: EstimatedTime | null;
|
||||
type: ExamType;
|
||||
form: QuestionForm;
|
||||
venue: string;
|
||||
administrationMethod: ExamAdministrationMethod;
|
||||
evaluationMethod: ExamEvaluationMethod;
|
||||
selectionMethod: ExamSelectionMethod;
|
||||
cuttingPoint: number;
|
||||
status: ExamStatus;
|
||||
questions?: QuestionBrief[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ListResponse<T> {
|
||||
total: number;
|
||||
items: T[];
|
||||
}
|
||||
|
||||
export interface CreateExamPayload {
|
||||
certificationId: string;
|
||||
title: LocalePair;
|
||||
direction?: LocalePair;
|
||||
date: string;
|
||||
givenTime: EstimatedTime;
|
||||
type: ExamType;
|
||||
form: QuestionForm;
|
||||
venue: string;
|
||||
administrationMethod: ExamAdministrationMethod;
|
||||
evaluationMethod: ExamEvaluationMethod;
|
||||
selectionMethod?: ExamSelectionMethod;
|
||||
cuttingPoint: number;
|
||||
}
|
||||
|
||||
export interface UpdateExamPayload {
|
||||
id: string;
|
||||
certificationId?: string;
|
||||
title?: LocalePair;
|
||||
direction?: LocalePair;
|
||||
date?: string;
|
||||
givenTime?: EstimatedTime;
|
||||
type?: ExamType;
|
||||
form?: QuestionForm;
|
||||
venue?: string;
|
||||
administrationMethod?: ExamAdministrationMethod;
|
||||
evaluationMethod?: ExamEvaluationMethod;
|
||||
selectionMethod?: ExamSelectionMethod;
|
||||
cuttingPoint?: number;
|
||||
status?: ExamStatus;
|
||||
}
|
||||
|
||||
export interface AssignQuestionsPayload {
|
||||
examId: string;
|
||||
questionIds: string[];
|
||||
remark?: LocalePair;
|
||||
}
|
||||
@@ -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;
|
||||
239
apps/backoffice/src/app/features/question/pages/QuestionPage.tsx
Normal file
239
apps/backoffice/src/app/features/question/pages/QuestionPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
45
apps/backoffice/src/app/features/question/types/question.ts
Normal file
45
apps/backoffice/src/app/features/question/types/question.ts
Normal 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;
|
||||
}
|
||||
45
apps/backoffice/src/app/features/result/api/result-api.ts
Normal file
45
apps/backoffice/src/app/features/result/api/result-api.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { baseApi } from '@ema-platform/api';
|
||||
import type {
|
||||
Result,
|
||||
ListResponse,
|
||||
CreateResultPayload,
|
||||
UpdateResultPayload,
|
||||
} from '../types/result';
|
||||
|
||||
const resultApi = baseApi.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
getResults: builder.query<ListResponse<Result>, void>({
|
||||
query: () => '/results',
|
||||
providesTags: ['Api'],
|
||||
}),
|
||||
getResult: builder.query<Result, string>({
|
||||
query: (id) => `/results/${id}`,
|
||||
providesTags: ['Api'],
|
||||
}),
|
||||
createResult: builder.mutation<Result, CreateResultPayload>({
|
||||
query: (body) => ({ url: '/results', method: 'POST', body }),
|
||||
invalidatesTags: ['Api'],
|
||||
}),
|
||||
updateResult: builder.mutation<Result, UpdateResultPayload>({
|
||||
query: ({ id, ...body }) => ({
|
||||
url: `/results/${id}`,
|
||||
method: 'PUT',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: ['Api'],
|
||||
}),
|
||||
deleteResult: builder.mutation<void, string>({
|
||||
query: (id) => ({ url: `/results/${id}`, method: 'DELETE' }),
|
||||
invalidatesTags: ['Api'],
|
||||
}),
|
||||
}),
|
||||
overrideExisting: false,
|
||||
});
|
||||
|
||||
export const {
|
||||
useGetResultsQuery,
|
||||
useGetResultQuery,
|
||||
useCreateResultMutation,
|
||||
useUpdateResultMutation,
|
||||
useDeleteResultMutation,
|
||||
} = resultApi;
|
||||
194
apps/backoffice/src/app/features/result/pages/ResultPage.tsx
Normal file
194
apps/backoffice/src/app/features/result/pages/ResultPage.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Stack,
|
||||
Title,
|
||||
Group,
|
||||
Table,
|
||||
Badge,
|
||||
Modal,
|
||||
Text,
|
||||
Paper,
|
||||
Loader,
|
||||
Center,
|
||||
Alert,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Divider,
|
||||
Button,
|
||||
} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { IconInfoCircle, IconEye } from '@tabler/icons-react';
|
||||
import { useGetResultsQuery } from '../api/result-api';
|
||||
import { useGetExamsQuery } from '../../exam/api/exam-api';
|
||||
import type { Result } from '../types/result';
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
PASSED: 'teal',
|
||||
FAILED: 'red',
|
||||
};
|
||||
|
||||
function ResultDetail({ result }: { result: Result }) {
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<div>
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Seafarer</Text>
|
||||
<Text fz="sm" fw={500}>
|
||||
{result.seafarer
|
||||
? `${result.seafarer.firstName} ${result.seafarer.middleName ?? ''} ${result.seafarer.lastName}`
|
||||
: result.seafarerId}
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Exam</Text>
|
||||
<Text fz="sm">{result.exam?.title?.en ?? result.examId}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Total Score</Text>
|
||||
<Text fz="sm" fw={700}>{result.totalScore}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Status</Text>
|
||||
<Badge size="sm" variant="light" color={STATUS_COLOR[result.status]}>{result.status}</Badge>
|
||||
</div>
|
||||
</SimpleGrid>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Text fw={600} fz="sm">Score Breakdown</Text>
|
||||
{result.resultBreakdowns.length === 0 ? (
|
||||
<Text fz="sm" c="dimmed">No breakdown data</Text>
|
||||
) : (
|
||||
<Table striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Question ID</Table.Th>
|
||||
<Table.Th>Score</Table.Th>
|
||||
<Table.Th>Remark</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{result.resultBreakdowns.map((b, i) => (
|
||||
<Table.Tr key={i}>
|
||||
<Table.Td><Text fz="xs">{b.questionId.slice(0, 8)}</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm" fw={600}>{b.score}</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm">{b.remark ?? '—'}</Text></Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
{result.remark && (
|
||||
<>
|
||||
<Divider />
|
||||
<div>
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Officer Remark</Text>
|
||||
<Text fz="sm">{result.remark.en || result.remark.am}</Text>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export function ResultPage() {
|
||||
const { data: examRes } = useGetExamsQuery();
|
||||
const { data, isLoading, isError } = useGetResultsQuery();
|
||||
|
||||
const exams = examRes?.items ?? [];
|
||||
const results = data?.items ?? [];
|
||||
|
||||
const [examFilter, setExamFilter] = useState<string | null>(null);
|
||||
const [selectedResult, setSelectedResult] = useState<Result | null>(null);
|
||||
const [detailOpened, { open: openDetail, close: closeDetail }] = useDisclosure(false);
|
||||
|
||||
const examOptions = exams.map((e) => ({ value: e.id, label: `${e.title.en} (${e.date})` }));
|
||||
|
||||
const filtered = results.filter((r) => !examFilter || r.examId === examFilter);
|
||||
|
||||
const getExamTitle = (id: string) => exams.find((e) => e.id === id)?.title?.en ?? '-';
|
||||
|
||||
const viewDetail = (result: Result) => {
|
||||
setSelectedResult(result);
|
||||
openDetail();
|
||||
};
|
||||
|
||||
if (isLoading) return <Center py="xl"><Loader /></Center>;
|
||||
if (isError) return <Alert icon={<IconInfoCircle size={16} />} color="red" title="Error loading results" />;
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Title order={2}>Exam Results</Title>
|
||||
<Text fz="sm" c="dimmed">View seafarer examination results and score breakdowns</Text>
|
||||
</div>
|
||||
|
||||
<Paper withBorder radius="md">
|
||||
<Group p="md" justify="space-between" wrap="wrap" gap="sm">
|
||||
<Text fw={600}>Results</Text>
|
||||
<Select
|
||||
placeholder="Filter by exam"
|
||||
data={[{ value: '', label: 'All Exams' }, ...examOptions]}
|
||||
value={examFilter}
|
||||
onChange={(v) => setExamFilter(v ?? null)}
|
||||
size="sm"
|
||||
style={{ width: 320 }}
|
||||
clearable
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
||||
<Table.Tr>
|
||||
<Table.Th>Seafarer</Table.Th>
|
||||
<Table.Th>Exam</Table.Th>
|
||||
<Table.Th>Total Score</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>Date</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{filtered.map((r) => (
|
||||
<Table.Tr key={r.id}>
|
||||
<Table.Td>
|
||||
<Text fz="sm" fw={500}>
|
||||
{r.seafarer ? `${r.seafarer.firstName} ${r.seafarer.lastName}` : r.seafarerId.slice(0, 8)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td><Text fz="sm">{r.exam ? r.exam.title.en : getExamTitle(r.examId)}</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm" fw={600}>{r.totalScore}</Text></Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" variant="light" color={STATUS_COLOR[r.status]}>{r.status}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td><Text fz="sm">{new Date(r.createdAt).toLocaleDateString()}</Text></Table.Td>
|
||||
<Table.Td>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconEye size={13} />}
|
||||
onClick={() => viewDetail(r)}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={6}>
|
||||
<Text c="dimmed" ta="center" py="xl">No results found</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
|
||||
<Modal opened={detailOpened} onClose={closeDetail} title="Result Detail" size="lg" radius="lg">
|
||||
{selectedResult && <ResultDetail result={selectedResult} />}
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
42
apps/backoffice/src/app/features/result/types/result.ts
Normal file
42
apps/backoffice/src/app/features/result/types/result.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
export type ExamResultStatus = 'PASSED' | 'FAILED';
|
||||
|
||||
export interface ResultBreakdown {
|
||||
questionId: string;
|
||||
score: number;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export interface Result {
|
||||
id: string;
|
||||
seafarerId: string;
|
||||
seafarer?: { id: string; firstName: string; middleName?: string; lastName: string };
|
||||
examId: string;
|
||||
exam?: { id: string; title: { en: string; am: string } };
|
||||
resultBreakdowns: ResultBreakdown[];
|
||||
totalScore: number;
|
||||
remark: { en: string; am: string } | null;
|
||||
status: ExamResultStatus;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ListResponse<T> {
|
||||
total: number;
|
||||
items: T[];
|
||||
}
|
||||
|
||||
export interface CreateResultPayload {
|
||||
seafarerId: string;
|
||||
examId: string;
|
||||
resultBreakdowns: ResultBreakdown[];
|
||||
totalScore?: number;
|
||||
remark?: { en: string; am: string };
|
||||
}
|
||||
|
||||
export interface UpdateResultPayload {
|
||||
id: string;
|
||||
resultBreakdowns?: ResultBreakdown[];
|
||||
totalScore?: number;
|
||||
remark?: { en: string; am: string };
|
||||
status?: ExamResultStatus;
|
||||
}
|
||||
@@ -20,6 +20,10 @@ import {
|
||||
IconUser,
|
||||
IconUsers,
|
||||
IconUserShield,
|
||||
IconCertificate,
|
||||
IconQuestionMark,
|
||||
IconClipboardList,
|
||||
IconReport,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { SUPPORTED_LANGUAGES } from '../i18n/config';
|
||||
@@ -37,6 +41,10 @@ const NAV_ITEMS: NavItem[] = [
|
||||
{ to: '/analytics', label: 'Analytics', icon: IconChartBar },
|
||||
{ to: '/medical-verification', label: 'Medical Verification', icon: IconHeart },
|
||||
{ to: '/locations', label: 'Locations', icon: IconMap },
|
||||
{ to: '/certifications', label: 'Certifications', icon: IconCertificate },
|
||||
{ to: '/questions', label: 'Questions', icon: IconQuestionMark },
|
||||
{ to: '/exams', label: 'Examinations', icon: IconClipboardList },
|
||||
{ to: '/exam-results', label: 'Exam Results', icon: IconReport },
|
||||
{ to: '/configuration', label: 'Configuration', icon: IconSettings },
|
||||
{ to: '/profile', label: 'Profile', icon: IconUser },
|
||||
];
|
||||
|
||||
@@ -26,6 +26,11 @@ import { MedicalVerificationPage } from '../features/medical-verification/pages/
|
||||
import { PaymentConfigPage } from '../features/payment-config/pages/PaymentConfigPage';
|
||||
import { SeafarerRegistryPage } from '../features/seafarer-registry/pages/SeafarerRegistryPage';
|
||||
import { SeamanBookQueuePage } from '../features/seaman-book-queue/pages/SeamanBookQueuePage';
|
||||
import { CertificationPage } from '../features/certification/pages/CertificationPage';
|
||||
import { QuestionPage } from '../features/question/pages/QuestionPage';
|
||||
import { ExamPage } from '../features/exam/pages/ExamPage';
|
||||
import { ExamDetailPage } from '../features/exam/pages/ExamDetailPage';
|
||||
import { ResultPage } from '../features/result/pages/ResultPage';
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
@@ -60,6 +65,11 @@ const router = createBrowserRouter([
|
||||
{ path: 'payment-config', element: <PaymentConfigPage /> },
|
||||
{ path: 'seafarer-registry', element: <SeafarerRegistryPage /> },
|
||||
{ path: 'seaman-book-queue', element: <SeamanBookQueuePage /> },
|
||||
{ path: 'certifications', element: <CertificationPage /> },
|
||||
{ path: 'questions', element: <QuestionPage /> },
|
||||
{ path: 'exams', element: <ExamPage /> },
|
||||
{ path: 'exams/:id', element: <ExamDetailPage /> },
|
||||
{ path: 'exam-results', element: <ResultPage /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user