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

View File

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

View File

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