mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-07 11:55:43 +00:00
Merge branch 'dev' of github.com:Tria-plc/emaui into Refactor
Resolved by unifying on the lib/data AdvancedTable (PR #10) as the canonical table component: kept its API plus teammate i18n/feature work, kept the folder-per-table structure (index.tsx + columns.tsx + actions.tsx) across all 27 tables, removed the parallel lib/table implementation, and fixed pre-existing compile breaks in ExamDetailPage/QuestionAssigner/RecordResultModal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { Badge, Button, Text } from '@mantine/core';
|
||||
import { IconUserCheck } from '@tabler/icons-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedTableColumn } from '@ema-platform/ui';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import type { AttendanceStatus, ExamRegistration } from '../../types/exam';
|
||||
|
||||
const ATTENDANCE_COLOR: Record<AttendanceStatus, string> = {
|
||||
@@ -25,68 +25,64 @@ export const candidateName = (registration: ExamRegistration) =>
|
||||
export function examCandidateColumns(
|
||||
t: TFunction,
|
||||
handlers: { onRecord: (registration: ExamRegistration) => void },
|
||||
): AdvancedTableColumn<ExamRegistration>[] {
|
||||
): AdvancedColumn<ExamRegistration>[] {
|
||||
return [
|
||||
{
|
||||
key: 'admission',
|
||||
header: t('exam.candidates.admission'),
|
||||
render: (registration) => (
|
||||
cell: ({ row }) => (
|
||||
<Text fz="sm" ff="monospace" fw={600}>
|
||||
{registration.admissionNumber}
|
||||
{row.original.admissionNumber}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'name',
|
||||
header: t('exam.candidates.name'),
|
||||
render: (registration) => <Text fz="sm">{candidateName(registration)}</Text>,
|
||||
cell: ({ row }) => <Text fz="sm">{candidateName(row.original)}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'attempt',
|
||||
header: t('exam.candidates.attempt'),
|
||||
render: (registration) => (
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={registration.kind === 'RETAKE' ? 'orange' : 'blue'}
|
||||
color={row.original.kind === 'RETAKE' ? 'orange' : 'blue'}
|
||||
>
|
||||
{registration.kind === 'RETAKE'
|
||||
? t('exam.candidates.retake', { n: registration.attemptNumber })
|
||||
{row.original.kind === 'RETAKE'
|
||||
? t('exam.candidates.retake', { n: row.original.attemptNumber })
|
||||
: t('exam.candidates.firstSitting')}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'attendance',
|
||||
header: t('exam.candidates.attendance'),
|
||||
render: (registration) => (
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={ATTENDANCE_COLOR[registration.attendanceStatus] ?? 'gray'}
|
||||
color={ATTENDANCE_COLOR[row.original.attendanceStatus] ?? 'gray'}
|
||||
>
|
||||
{t(`exam.attendance.${registration.attendanceStatus}`)}
|
||||
{t(`exam.attendance.${row.original.attendanceStatus}`)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'remark',
|
||||
header: t('exam.candidates.remark'),
|
||||
render: (registration) => (
|
||||
cell: ({ row }) => (
|
||||
<Text fz="xs" c="dimmed" maw={220} lineClamp={2}>
|
||||
{registration.attendanceRemark ?? '—'}
|
||||
{row.original.attendanceRemark ?? '—'}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'record',
|
||||
header: '',
|
||||
render: (registration) => (
|
||||
label: t('exam.candidates.record'),
|
||||
align: 'right',
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
leftSection={<IconUserCheck size={12} />}
|
||||
onClick={() => handlers.onRecord(registration)}
|
||||
onClick={() => handlers.onRecord(row.original)}
|
||||
>
|
||||
{t('exam.candidates.record')}
|
||||
</Button>
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconInfoCircle } from '@tabler/icons-react';
|
||||
import { AdvancedTable, notify } from '@ema-platform/ui';
|
||||
import { AdvancedTable, notify, useServerTable } from '@ema-platform/ui';
|
||||
import { extractErrorMessage } from '@ema-platform/api';
|
||||
import {
|
||||
useGetExamRegistrationsQuery,
|
||||
@@ -46,6 +46,7 @@ export function ExamCandidatesPanel({ examId }: { examId: string }) {
|
||||
const [target, setTarget] = useState<ExamRegistration | null>(null);
|
||||
const [status, setStatus] = useState<AttendanceStatus>('PRESENT');
|
||||
const [remark, setRemark] = useState('');
|
||||
const table = useServerTable();
|
||||
|
||||
const startRecording = (registration: ExamRegistration) => {
|
||||
setTarget(registration);
|
||||
@@ -81,6 +82,8 @@ export function ExamCandidatesPanel({ examId }: { examId: string }) {
|
||||
// register; the endpoint refuses them and there is nothing to show.
|
||||
if (isError) return null;
|
||||
|
||||
const paged = table.paginate(registrations ?? []);
|
||||
|
||||
return (
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Title order={5} mb="md">
|
||||
@@ -92,10 +95,14 @@ export function ExamCandidatesPanel({ examId }: { examId: string }) {
|
||||
</Alert>
|
||||
) : (
|
||||
<AdvancedTable
|
||||
tableName={t('exam.candidates.section')}
|
||||
columns={examCandidateColumns(t, { onRecord: startRecording })}
|
||||
data={registrations ?? []}
|
||||
rowKey={(registration) => registration.id}
|
||||
onRefresh={refetch}
|
||||
data={paged.rows}
|
||||
itemCount={paged.itemCount}
|
||||
pageIndex={paged.pageIndex}
|
||||
onPageChange={table.setPageIndex}
|
||||
pageSize={table.pageSize}
|
||||
refresh={refetch}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Badge, Button, Text } from '@mantine/core';
|
||||
import { IconAlertTriangle } from '@tabler/icons-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedTableColumn } from '@ema-platform/ui';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import type { ExamIncident, ExamIncidentStatus } from '../../types/exam';
|
||||
|
||||
const STATUS_COLOR: Record<ExamIncidentStatus, string> = {
|
||||
@@ -13,75 +13,72 @@ const STATUS_COLOR: Record<ExamIncidentStatus, string> = {
|
||||
|
||||
export function examIncidentColumns(
|
||||
t: TFunction,
|
||||
showDate: (date: string | null | undefined) => string,
|
||||
handlers: { onResolve: (incident: ExamIncident) => void },
|
||||
): AdvancedTableColumn<ExamIncident>[] {
|
||||
): AdvancedColumn<ExamIncident>[] {
|
||||
return [
|
||||
{
|
||||
key: 'type',
|
||||
header: t('exam.incidents.type'),
|
||||
render: (incident) => (
|
||||
cell: ({ row }) => (
|
||||
<Badge size="sm" variant="light" color="orange">
|
||||
{t(`exam.incidentType.${incident.type}`)}
|
||||
{t(`exam.incidentType.${row.original.type}`)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'candidate',
|
||||
header: t('exam.incidents.candidate'),
|
||||
render: (incident) => (
|
||||
cell: ({ row }) => (
|
||||
<Text fz="xs">
|
||||
{incident.registration?.admissionNumber ?? t('exam.incidents.wholeRoom')}
|
||||
{row.original.registration?.admissionNumber ?? t('exam.incidents.wholeRoom')}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'description',
|
||||
header: t('exam.incidents.description'),
|
||||
render: (incident) => (
|
||||
cell: ({ row }) => (
|
||||
<>
|
||||
<Text fz="xs" maw={260} lineClamp={2}>
|
||||
{incident.description}
|
||||
{row.original.description}
|
||||
</Text>
|
||||
{incident.resolution && (
|
||||
{row.original.resolution && (
|
||||
<Text fz="xs" c="dimmed" maw={260} lineClamp={2}>
|
||||
⤷ {incident.resolution}
|
||||
⤷ {row.original.resolution}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'occurred',
|
||||
header: t('exam.incidents.occurred'),
|
||||
render: (incident) => <Text fz="xs">{incident.occurredAt?.slice(0, 10)}</Text>,
|
||||
cell: ({ row }) => <Text fz="xs">{showDate(row.original.occurredAt)}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: t('exam.incidents.status'),
|
||||
render: (incident) => (
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={STATUS_COLOR[incident.status] ?? 'gray'}
|
||||
color={STATUS_COLOR[row.original.status] ?? 'gray'}
|
||||
>
|
||||
{t(`exam.incidentStatus.${incident.status}`)}
|
||||
{t(`exam.incidentStatus.${row.original.status}`)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'resolve',
|
||||
header: '',
|
||||
render: (incident) =>
|
||||
(incident.status === 'OPEN' || incident.status === 'UNDER_REVIEW') && (
|
||||
label: t('exam.incidents.resolve'),
|
||||
align: 'right',
|
||||
cell: ({ row }) =>
|
||||
row.original.status === 'OPEN' || row.original.status === 'UNDER_REVIEW' ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
leftSection={<IconAlertTriangle size={12} />}
|
||||
onClick={() => handlers.onResolve(incident)}
|
||||
onClick={() => handlers.onResolve(row.original)}
|
||||
>
|
||||
{t('exam.incidents.resolve')}
|
||||
</Button>
|
||||
),
|
||||
) : null,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -13,7 +13,8 @@ import {
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconInfoCircle, IconPlus } from '@tabler/icons-react';
|
||||
import { AdvancedTable, notify } from '@ema-platform/ui';
|
||||
import { AdvancedTable, notify, useServerTable } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import { extractErrorMessage } from '@ema-platform/api';
|
||||
import {
|
||||
useGetExamIncidentsQuery,
|
||||
@@ -39,7 +40,9 @@ const TYPES: ExamIncidentType[] = [
|
||||
*/
|
||||
export function ExamIncidentsPanel({ examId }: { examId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const showDate = useDateDisplayer();
|
||||
const { data: incidents, isError, refetch } = useGetExamIncidentsQuery(examId);
|
||||
const table = useServerTable();
|
||||
const { data: registrations } = useGetExamRegistrationsQuery(examId);
|
||||
const [recordIncident, { isLoading: isFiling }] = useRecordIncidentMutation();
|
||||
const [resolveIncident, { isLoading: isResolving }] = useResolveIncidentMutation();
|
||||
@@ -113,6 +116,8 @@ export function ExamIncidentsPanel({ examId }: { examId: string }) {
|
||||
// endpoint refuses them, so there is nothing to render.
|
||||
if (isError) return null;
|
||||
|
||||
const paged = table.paginate(incidents ?? []);
|
||||
|
||||
return (
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Group justify="space-between" mb="md">
|
||||
@@ -133,11 +138,15 @@ export function ExamIncidentsPanel({ examId }: { examId: string }) {
|
||||
{t('exam.incidents.none')}
|
||||
</Alert>
|
||||
) : (
|
||||
<AdvancedTable
|
||||
columns={examIncidentColumns(t, { onResolve: startResolve })}
|
||||
data={incidents ?? []}
|
||||
rowKey={(incident) => incident.id}
|
||||
onRefresh={refetch}
|
||||
<AdvancedTable<ExamIncident>
|
||||
tableName={t('exam.incidents.section')}
|
||||
columns={examIncidentColumns(t, showDate, { onResolve: startResolve })}
|
||||
data={paged.rows}
|
||||
itemCount={paged.itemCount}
|
||||
pageIndex={paged.pageIndex}
|
||||
onPageChange={table.setPageIndex}
|
||||
pageSize={table.pageSize}
|
||||
refresh={refetch}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { Dispatch, SetStateAction, useState } from "react";
|
||||
import {
|
||||
Paper,
|
||||
Group,
|
||||
@@ -10,16 +10,19 @@ import {
|
||||
Checkbox,
|
||||
Box,
|
||||
Button,
|
||||
} from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IconSearch } from '@tabler/icons-react';
|
||||
import type { QuestionBrief } from '../types/exam';
|
||||
} from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IconSearch } from "@tabler/icons-react";
|
||||
import type { QuestionBrief } from "../types/exam";
|
||||
|
||||
type actionTypes = "add" | "remove";
|
||||
|
||||
interface QuestionAssignerProps {
|
||||
available: QuestionBrief[];
|
||||
assigned: QuestionBrief[];
|
||||
onChange: (assigned: QuestionBrief[]) => void;
|
||||
mode?: 'manual' | 'random';
|
||||
mode?: "manual" | "random";
|
||||
actions?: Dispatch<SetStateAction<actionTypes | undefined>>;
|
||||
}
|
||||
|
||||
function QuestionList({
|
||||
@@ -38,11 +41,13 @@ function QuestionList({
|
||||
label: string;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.language as 'en' | 'am';
|
||||
const placeholder = t('exam.assigner.search');
|
||||
const locale = i18n.language as "en" | "am";
|
||||
const placeholder = t("exam.assigner.search");
|
||||
return (
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fz="xs" fw={600} c="dimmed" mb={4}>{label} ({items.length})</Text>
|
||||
<Text fz="xs" fw={600} c="dimmed" mb={4}>
|
||||
{label} ({items.length})
|
||||
</Text>
|
||||
<Paper withBorder radius="md">
|
||||
<Group p="sm" pb={0}>
|
||||
<TextInput
|
||||
@@ -57,7 +62,9 @@ function QuestionList({
|
||||
<ScrollArea h={280} p="sm" pt="xs">
|
||||
<Stack gap={4}>
|
||||
{items.length === 0 && (
|
||||
<Text fz="xs" c="dimmed" ta="center" py="xl">{t('exam.assigner.noQuestions')}</Text>
|
||||
<Text fz="xs" c="dimmed" ta="center" py="xl">
|
||||
{t("exam.assigner.noQuestions")}
|
||||
</Text>
|
||||
)}
|
||||
{items.map((q) => (
|
||||
<Paper
|
||||
@@ -66,19 +73,37 @@ function QuestionList({
|
||||
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,
|
||||
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" />
|
||||
<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[locale]}</Text>
|
||||
<Text fz="xs" lineClamp={2}>
|
||||
{q.title[locale]}
|
||||
</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>
|
||||
<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>
|
||||
@@ -91,49 +116,71 @@ function QuestionList({
|
||||
);
|
||||
}
|
||||
|
||||
export function QuestionAssigner({ available, assigned, onChange, mode = 'manual' }: QuestionAssignerProps) {
|
||||
export function QuestionAssigner({
|
||||
available,
|
||||
assigned,
|
||||
onChange,
|
||||
mode = "manual",
|
||||
actions,
|
||||
}: QuestionAssignerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [searchLeft, setSearchLeft] = useState('');
|
||||
const [searchRight, setSearchRight] = useState('');
|
||||
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))
|
||||
(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)
|
||||
(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]);
|
||||
actions?.("add");
|
||||
setSelectedLeft(new Set());
|
||||
};
|
||||
|
||||
const removeSelected = () => {
|
||||
onChange(assigned.filter((q) => !selectedRight.has(q.id)));
|
||||
actions?.("remove");
|
||||
setSelectedRight(new Set());
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
{mode === 'manual' && <Text fz="sm" fw={500}>{t('exam.assigner.title')}</Text>}
|
||||
{mode === 'random' && <Text fz="sm" fw={500}>{t('exam.assigner.assignedTitle')}</Text>}
|
||||
{mode === "manual" && (
|
||||
<Text fz="sm" fw={500}>
|
||||
{t("exam.assigner.title")}
|
||||
</Text>
|
||||
)}
|
||||
{mode === "random" && (
|
||||
<Text fz="sm" fw={500}>
|
||||
{t("exam.assigner.assignedTitle")}
|
||||
</Text>
|
||||
)}
|
||||
<Group gap="sm" align="stretch" wrap="nowrap">
|
||||
{mode === 'manual' && (
|
||||
{mode === "manual" && (
|
||||
<QuestionList
|
||||
items={filteredAvailable}
|
||||
selected={selectedLeft}
|
||||
onToggle={(id) => {
|
||||
const next = new Set(selectedLeft);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
setSelectedLeft(next);
|
||||
}}
|
||||
search={searchLeft}
|
||||
onSearchChange={setSearchLeft}
|
||||
label={t('exam.assigner.available')}
|
||||
label={t("exam.assigner.available")}
|
||||
/>
|
||||
)}
|
||||
<QuestionList
|
||||
@@ -141,32 +188,43 @@ export function QuestionAssigner({ available, assigned, onChange, mode = 'manual
|
||||
selected={selectedRight}
|
||||
onToggle={(id) => {
|
||||
const next = new Set(selectedRight);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
setSelectedRight(next);
|
||||
}}
|
||||
search={searchRight}
|
||||
onSearchChange={setSearchRight}
|
||||
label={t('exam.assigner.assigned')}
|
||||
label={t("exam.assigner.assigned")}
|
||||
/>
|
||||
</Group>
|
||||
{mode === 'manual' && (
|
||||
{mode === "manual" && (
|
||||
<Group gap="sm" justify="center">
|
||||
{selectedLeft.size > 0 && (
|
||||
<Button size="xs" variant="light" onClick={assignSelected}>
|
||||
{t('exam.assigner.assignSelected', { count: selectedLeft.size })}
|
||||
{t("exam.assigner.assignSelected", { count: selectedLeft.size })}
|
||||
</Button>
|
||||
)}
|
||||
{selectedRight.size > 0 && (
|
||||
<Button size="xs" variant="light" color="red" onClick={removeSelected}>
|
||||
{t('exam.assigner.removeSelected', { count: selectedRight.size })}
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="red"
|
||||
onClick={removeSelected}
|
||||
>
|
||||
{t("exam.assigner.removeSelected", { count: selectedRight.size })}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
{mode === 'random' && selectedRight.size > 0 && (
|
||||
{mode === "random" && selectedRight.size > 0 && (
|
||||
<Group gap="sm" justify="center">
|
||||
<Button size="xs" variant="light" color="red" onClick={removeSelected}>
|
||||
{t('exam.assigner.removeSelected', { count: selectedRight.size })}
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="red"
|
||||
onClick={removeSelected}
|
||||
>
|
||||
{t("exam.assigner.removeSelected", { count: selectedRight.size })}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useState, useEffect, useRef, useMemo } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
Stack,
|
||||
Title,
|
||||
@@ -22,9 +22,9 @@ import {
|
||||
ThemeIcon,
|
||||
Box,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
} from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
IconArrowLeft,
|
||||
IconPrinter,
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
IconCheck,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { ModalFooter, notify, useErrorHandler } from '@ema-platform/ui';
|
||||
import { extractErrorMessage } from '@ema-platform/api';
|
||||
import {
|
||||
useGetExamQuery,
|
||||
@@ -56,32 +56,50 @@ import { ExamIncidentsPanel } from '../components/ExamIncidentsPanel';
|
||||
import type { ExamStatus, QuestionBrief } from '../types/exam';
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
PENDING: 'gray', ACTIVE: 'blue', COMPLETED: 'teal',
|
||||
CANCELLED: 'red', POSTPONED: 'orange', PUBLISHED: 'green',
|
||||
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' };
|
||||
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>
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text fz="sm" fw={500}>
|
||||
{value || "—"}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExamDetailPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.language as 'en' | 'am';
|
||||
const locale = i18n.language as "en" | "am";
|
||||
const { handleError } = useErrorHandler();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const printRef = useRef<HTMLDivElement>(null);
|
||||
const [recordOpened, { open: openRecord, close: closeRecord }] = useDisclosure(false);
|
||||
const [assignOpened, { open: openAssign, close: closeAssign }] = useDisclosure(false);
|
||||
const [recordOpened, { open: openRecord, close: closeRecord }] =
|
||||
useDisclosure(false);
|
||||
const [assignOpened, { open: openAssign, close: closeAssign }] =
|
||||
useDisclosure(false);
|
||||
const [draftQuestions, setDraftQuestions] = useState<QuestionBrief[]>([]);
|
||||
const [randomCount, setRandomCount] = useState(5);
|
||||
const [updateExam] = useUpdateExamMutation();
|
||||
@@ -109,12 +127,26 @@ export function ExamDetailPage() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [allQuestions, exam?.certificationId, exam?.form]);
|
||||
|
||||
if (isLoading) return <Center py="xl"><Loader /></Center>;
|
||||
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')}>{t('exam.backToExams')}</Button>
|
||||
<Alert color="red" icon={<IconInfoCircle size={17} />}>{t('exam.notFound')}</Alert>
|
||||
<Button
|
||||
variant="subtle"
|
||||
leftSection={<IconArrowLeft size={15} />}
|
||||
w="fit-content"
|
||||
onClick={() => navigate("/exams")}
|
||||
>
|
||||
{t("exam.backToExams")}
|
||||
</Button>
|
||||
<Alert color="red" icon={<IconInfoCircle size={17} />}>
|
||||
{t("exam.notFound")}
|
||||
</Alert>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -161,42 +193,51 @@ export function ExamDetailPage() {
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrint = async () => {
|
||||
const total = (exam.questions ?? []).reduce((s, q) => s + Number(q.points), 0);
|
||||
const total = (exam.questions ?? []).reduce(
|
||||
(s, q) => s + Number(q.points),
|
||||
0,
|
||||
);
|
||||
if (total < Number(exam.cuttingPoint)) {
|
||||
notify.error(`Total question marks (${total}) is less than the passing mark (${exam.cuttingPoint}). Add more questions or adjust the cutting point before printing.`);
|
||||
notify.error(
|
||||
`Total question marks (${total}) is less than the passing mark (${exam.cuttingPoint}). Add more questions or adjust the cutting point before printing.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const printWindow = window.open('', '_blank');
|
||||
const printWindow = window.open("", "_blank");
|
||||
if (!printWindow) return;
|
||||
|
||||
let logoBase64 = '';
|
||||
let logoBase64 = "";
|
||||
try {
|
||||
const resp = await fetch('/ema-logo.png');
|
||||
const resp = await fetch("/ema-logo.png");
|
||||
const blob = await resp.blob();
|
||||
logoBase64 = await new Promise<string>((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => resolve(reader.result as string);
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
} catch { /* logo not available */ }
|
||||
} catch {
|
||||
/* logo not available */
|
||||
}
|
||||
|
||||
const qMap = new Map(allQuestions.map((qq) => [qq.id, qq]));
|
||||
const qHtml = (exam.questions ?? []).map((q, i) => {
|
||||
const full = qMap.get(q.id);
|
||||
const titleStr = q.title[locale] || q.title.en;
|
||||
const descStr = full?.description?.[locale] || full?.description?.en || '';
|
||||
return `
|
||||
const qHtml = (exam.questions ?? [])
|
||||
.map((q, i) => {
|
||||
const full = qMap.get(q.id);
|
||||
const titleStr = q.title[locale] || q.title.en;
|
||||
const descStr =
|
||||
full?.description?.[locale] || full?.description?.en || "";
|
||||
return `
|
||||
<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 4px 0; font-size: 14px; line-height: 1.5;">${titleStr}</p>
|
||||
${descStr ? `<p style="margin: 0 0 8px 0; font-size: 12px; color: #555; line-height: 1.4;">${descStr}</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('') : ''}
|
||||
${descStr ? `<p style="margin: 0 0 8px 0; font-size: 12px; color: #555; line-height: 1.4;">${descStr}</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('');
|
||||
})
|
||||
.join("");
|
||||
|
||||
printWindow.document.write(`
|
||||
<html><head><title>${exam.title[locale] || exam.title.en}</title>
|
||||
@@ -211,13 +252,13 @@ export function ExamDetailPage() {
|
||||
@media print { @page { margin: 20mm; } body { -webkit-print-color-adjust: exact; } }
|
||||
</style></head><body>
|
||||
<div class="header">
|
||||
${logoBase64 ? `<img src="${logoBase64}" alt="Logo" class="header-logo" />` : ''}
|
||||
${logoBase64 ? `<img src="${logoBase64}" alt="Logo" class="header-logo" />` : ""}
|
||||
<h1>${exam.title[locale] || exam.title.en}</h1>
|
||||
<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>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?.[locale] ? `<div class="directions"><strong>Directions:</strong> ${exam.direction[locale]}</div>` : ''}
|
||||
${exam.direction?.[locale] ? `<div class="directions"><strong>Directions:</strong> ${exam.direction[locale]}</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
|
||||
@@ -229,15 +270,25 @@ export function ExamDetailPage() {
|
||||
setTimeout(() => printWindow.print(), 500);
|
||||
};
|
||||
|
||||
const totalPoints = (exam.questions ?? []).reduce((s, q) => s + Number(q.points), 0);
|
||||
const certName = exam.certification?.name?.[locale] ?? certifications.find((c) => c.id === exam.certificationId)?.name?.[locale] ?? '—';
|
||||
const totalPoints = (exam.questions ?? []).reduce(
|
||||
(s, q) => s + Number(q.points),
|
||||
0,
|
||||
);
|
||||
const certName =
|
||||
exam.certification?.name?.[locale] ??
|
||||
certifications.find((c) => c.id === exam.certificationId)?.name?.[locale] ??
|
||||
"—";
|
||||
|
||||
return (
|
||||
<Stack gap="md" ref={printRef}>
|
||||
{/* Header */}
|
||||
<Group justify="space-between">
|
||||
<Group gap="sm">
|
||||
<ActionIcon variant="subtle" size="lg" onClick={() => navigate('/exams')}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
onClick={() => navigate("/exams")}
|
||||
>
|
||||
<IconArrowLeft size={18} />
|
||||
</ActionIcon>
|
||||
<div>
|
||||
@@ -245,41 +296,93 @@ export function ExamDetailPage() {
|
||||
</div>
|
||||
</Group>
|
||||
<Group gap="sm">
|
||||
<Button variant="light" leftSection={<IconPrinter size={15} />} onClick={handlePrint} size="sm">
|
||||
{t('exam.print')}
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<IconPrinter size={15} />}
|
||||
onClick={handlePrint}
|
||||
size="sm"
|
||||
>
|
||||
{t("exam.print")}
|
||||
</Button>
|
||||
<Button leftSection={<IconPlus size={15} />} onClick={openRecord} size="sm">
|
||||
{t('exam.recordResult')}
|
||||
<Button
|
||||
leftSection={<IconPlus size={15} />}
|
||||
onClick={openRecord}
|
||||
size="sm"
|
||||
>
|
||||
{t("exam.recordResult")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Status badge */}
|
||||
<Badge size="lg" variant="light" color={STATUS_COLOR[exam.status]} style={{ width: 'fit-content' }}>
|
||||
<Badge
|
||||
size="lg"
|
||||
variant="light"
|
||||
color={STATUS_COLOR[exam.status]}
|
||||
style={{ width: "fit-content" }}
|
||||
>
|
||||
{t(`exam.status.${exam.status}`)}
|
||||
</Badge>
|
||||
|
||||
{/* Exam Info */}
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Title order={5} mb="md">{t('exam.detail.title')}</Title>
|
||||
<Title order={5} mb="md">
|
||||
{t("exam.detail.title")}
|
||||
</Title>
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
|
||||
<InfoRow label={t('exam.detail.certification')} value={certName} />
|
||||
<InfoRow label={t('exam.detail.type')} value={t(`exam.type.${exam.type}`)} />
|
||||
<InfoRow label={t('exam.detail.form')} value={t(`exam.formType.${exam.form}`)} />
|
||||
<InfoRow label={t('exam.detail.venue')} value={exam.venue} />
|
||||
<InfoRow label={t('exam.detail.date')} value={exam.date} />
|
||||
<InfoRow label={t('exam.detail.administration')} value={t(`exam.admin.${exam.administrationMethod}`)} />
|
||||
<InfoRow label={t('exam.detail.evaluation')} value={t(`exam.eval.${exam.evaluationMethod}`)} />
|
||||
<InfoRow label={t('exam.detail.selection')} value={t(`exam.selection.${exam.selectionMethod}`)} />
|
||||
<InfoRow label={t('exam.detail.timeAllowed')} value={exam.givenTime ? `${exam.givenTime.days}d ${exam.givenTime.hours}h ${exam.givenTime.minutes}m` : '—'} />
|
||||
<InfoRow label={t('exam.detail.passMark')} value={String(exam.cuttingPoint)} />
|
||||
<InfoRow label={t('exam.detail.totalPoints')} value={String(totalPoints)} />
|
||||
<InfoRow label={t('exam.detail.questions')} value={String((exam.questions ?? []).length)} />
|
||||
<InfoRow label={t("exam.detail.certification")} value={certName} />
|
||||
<InfoRow
|
||||
label={t("exam.detail.type")}
|
||||
value={t(`exam.type.${exam.type}`)}
|
||||
/>
|
||||
<InfoRow
|
||||
label={t("exam.detail.form")}
|
||||
value={t(`exam.formType.${exam.form}`)}
|
||||
/>
|
||||
<InfoRow label={t("exam.detail.venue")} value={exam.venue} />
|
||||
<InfoRow label={t("exam.detail.date")} value={exam.date} />
|
||||
<InfoRow
|
||||
label={t("exam.detail.administration")}
|
||||
value={t(`exam.admin.${exam.administrationMethod}`)}
|
||||
/>
|
||||
<InfoRow
|
||||
label={t("exam.detail.evaluation")}
|
||||
value={t(`exam.eval.${exam.evaluationMethod}`)}
|
||||
/>
|
||||
<InfoRow
|
||||
label={t("exam.detail.selection")}
|
||||
value={t(`exam.selection.${exam.selectionMethod}`)}
|
||||
/>
|
||||
<InfoRow
|
||||
label={t("exam.detail.timeAllowed")}
|
||||
value={
|
||||
exam.givenTime
|
||||
? `${exam.givenTime.days}d ${exam.givenTime.hours}h ${exam.givenTime.minutes}m`
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
<InfoRow
|
||||
label={t("exam.detail.passMark")}
|
||||
value={String(exam.cuttingPoint)}
|
||||
/>
|
||||
<InfoRow
|
||||
label={t("exam.detail.totalPoints")}
|
||||
value={String(totalPoints)}
|
||||
/>
|
||||
<InfoRow
|
||||
label={t("exam.detail.questions")}
|
||||
value={String((exam.questions ?? []).length)}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
{(exam.direction?.en || exam.direction?.am) && (
|
||||
<>
|
||||
<Divider my="md" />
|
||||
<InfoRow label={t('exam.detail.directions')} value={[exam.direction?.en, exam.direction?.am].filter(Boolean).join(' / ')} />
|
||||
<InfoRow
|
||||
label={t("exam.detail.directions")}
|
||||
value={[exam.direction?.en, exam.direction?.am]
|
||||
.filter(Boolean)
|
||||
.join(" / ")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
@@ -287,24 +390,41 @@ export function ExamDetailPage() {
|
||||
{/* Questions */}
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Title order={5}>{t('exam.detail.questionsSection', { pts: totalPoints })}</Title>
|
||||
<Button variant="light" size="xs" leftSection={<IconPlus size={14} />} onClick={openAssignModal}>
|
||||
{t('exam.manageQuestions')}
|
||||
<Title order={5}>
|
||||
{t("exam.detail.questionsSection", { pts: totalPoints })}
|
||||
</Title>
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
onClick={openAssignModal}
|
||||
>
|
||||
{t("exam.manageQuestions")}
|
||||
</Button>
|
||||
</Group>
|
||||
{(exam.questions ?? []).length === 0 ? (
|
||||
<Alert color="gray" icon={<IconInfoCircle size={16} />}>
|
||||
{t('exam.noQuestionsAssigned')}
|
||||
{t("exam.noQuestionsAssigned")}
|
||||
</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}>{t('exam.detail.questionLabel')} {i + 1}</Text>
|
||||
<Text fz="sm" fw={700}>
|
||||
{t("exam.detail.questionLabel")} {i + 1}
|
||||
</Text>
|
||||
<Group gap={4}>
|
||||
<Badge size="xs" variant="light" color={q.form === 'ESSAY' ? 'blue' : 'violet'}>{t(`exam.formType.${q.form}`)}</Badge>
|
||||
<Badge size="xs" variant="light" color="gray">{q.points} pts</Badge>
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color={q.form === "ESSAY" ? "blue" : "violet"}
|
||||
>
|
||||
{t(`exam.formType.${q.form}`)}
|
||||
</Badge>
|
||||
<Badge size="xs" variant="light" color="gray">
|
||||
{q.points} pts
|
||||
</Badge>
|
||||
</Group>
|
||||
</Group>
|
||||
<Text fz="sm">{q.title[locale]}</Text>
|
||||
@@ -321,9 +441,15 @@ export function ExamDetailPage() {
|
||||
<RecordResultModal exam={exam} opened={recordOpened} onClose={closeRecord} />
|
||||
|
||||
{/* Question assignment modal */}
|
||||
<Modal opened={assignOpened} onClose={closeAssign} title={`${t('exam.manageQuestions')} — ${exam.title[locale]}`} size="xl" radius="lg">
|
||||
<Modal
|
||||
opened={assignOpened}
|
||||
onClose={closeAssign}
|
||||
title={`${t("exam.manageQuestions")} — ${exam.title[locale]}`}
|
||||
size="xl"
|
||||
radius="lg"
|
||||
>
|
||||
<Stack gap="md">
|
||||
{exam.selectionMethod === 'MANUAL' ? (
|
||||
{exam.selectionMethod === "MANUAL" ? (
|
||||
<>
|
||||
<QuestionAssigner
|
||||
available={eligibleQuestions}
|
||||
@@ -331,17 +457,21 @@ export function ExamDetailPage() {
|
||||
onChange={setDraftQuestions}
|
||||
mode="manual"
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={closeAssign} size="sm">{t('exam.cancel')}</Button>
|
||||
<Button onClick={handleAssign} size="sm" loading={isAssigning}>{t('exam.saveAssignments')}</Button>
|
||||
</Group>
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={closeAssign} size="sm">
|
||||
{t("exam.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleAssign} size="sm" loading={isAssigning}>
|
||||
{t("exam.saveAssignments")}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text fz="sm" c="dimmed">{t('exam.randomHintServer')}</Text>
|
||||
<Group gap="sm">
|
||||
<NumberInput
|
||||
placeholder={t('exam.assigner.selectCount')}
|
||||
placeholder={t("exam.assigner.selectCount")}
|
||||
value={randomCount}
|
||||
onChange={(v) => setRandomCount(Number(v))}
|
||||
min={1}
|
||||
@@ -358,10 +488,14 @@ export function ExamDetailPage() {
|
||||
onChange={setDraftQuestions}
|
||||
mode="random"
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={closeAssign} size="sm">{t('exam.cancel')}</Button>
|
||||
<Button onClick={handleAssign} size="sm" loading={isAssigning}>{t('exam.saveAssignments')}</Button>
|
||||
</Group>
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={closeAssign} size="sm">
|
||||
{t("exam.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleAssign} size="sm" loading={isAssigning}>
|
||||
{t("exam.saveAssignments")}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
@@ -1,29 +1,47 @@
|
||||
import { IconEdit, IconTrash } from '@tabler/icons-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedTableAction } from '@ema-platform/ui';
|
||||
import type { Exam } from '../../types/exam';
|
||||
import { ActionIcon, Group } from "@mantine/core";
|
||||
import { IconEdit, IconTrash, IconDetails } from "@tabler/icons-react";
|
||||
import type { TFunction } from "i18next";
|
||||
import type { AdvancedColumn } from "@ema-platform/ui";
|
||||
import type { Exam } from "../../types/exam";
|
||||
|
||||
export function examColumnActions(
|
||||
export function examActionsColumn(
|
||||
t: TFunction,
|
||||
handlers: {
|
||||
onEdit: (exam: Exam) => void;
|
||||
onDelete: (exam: Exam) => void;
|
||||
onDetails: (exam: Exam) => void;
|
||||
},
|
||||
): AdvancedTableAction<Exam>[] {
|
||||
return [
|
||||
{
|
||||
key: 'edit',
|
||||
label: t('exam.update'),
|
||||
color: 'blue',
|
||||
icon: <IconEdit size={14} />,
|
||||
onClick: handlers.onEdit,
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: t('exam.delete'),
|
||||
color: 'red',
|
||||
icon: <IconTrash size={14} />,
|
||||
onClick: handlers.onDelete,
|
||||
},
|
||||
];
|
||||
): AdvancedColumn<Exam> {
|
||||
return {
|
||||
header: t("exam.columns.actions"),
|
||||
align: "right",
|
||||
cell: ({ row }) => (
|
||||
<Group gap="xs">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="blue"
|
||||
size="sm"
|
||||
onClick={() => handlers.onEdit(row.original)}
|
||||
>
|
||||
<IconEdit size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="sm"
|
||||
onClick={() => handlers.onDelete(row.original)}
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="sm"
|
||||
onClick={() => handlers.onDetails(row.original)}
|
||||
>
|
||||
<IconDetails size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,89 +1,79 @@
|
||||
import { Badge, Text } from '@mantine/core';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedTableColumn } from '@ema-platform/ui';
|
||||
import type { Exam } from '../../types/exam';
|
||||
import { Badge, Text } from "@mantine/core";
|
||||
import type { TFunction } from "i18next";
|
||||
import type { AdvancedColumn } from "@ema-platform/ui";
|
||||
import type { Exam } from "../../types/exam";
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
PENDING: 'gray',
|
||||
ACTIVE: 'blue',
|
||||
COMPLETED: 'teal',
|
||||
CANCELLED: 'red',
|
||||
POSTPONED: 'orange',
|
||||
PUBLISHED: 'green',
|
||||
PENDING: "gray",
|
||||
ACTIVE: "blue",
|
||||
COMPLETED: "teal",
|
||||
CANCELLED: "red",
|
||||
POSTPONED: "orange",
|
||||
PUBLISHED: "green",
|
||||
};
|
||||
|
||||
export function examColumns(
|
||||
t: TFunction,
|
||||
handlers: {
|
||||
locale: 'en' | 'am';
|
||||
getCertName: (id: string) => string;
|
||||
onTitleClick: (exam: Exam) => void;
|
||||
},
|
||||
): AdvancedTableColumn<Exam>[] {
|
||||
locale: "en" | "am",
|
||||
getCertName: (id: string) => string,
|
||||
onTitleClick: (exam: Exam) => void,
|
||||
): AdvancedColumn<Exam>[] {
|
||||
return [
|
||||
{
|
||||
key: 'title',
|
||||
header: t('exam.columns.title'),
|
||||
render: (exam) => (
|
||||
header: t("exam.columns.title"),
|
||||
cell: ({ row }) => (
|
||||
<Text
|
||||
fz="sm"
|
||||
fw={500}
|
||||
c="blue"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => handlers.onTitleClick(exam)}
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => onTitleClick(row.original)}
|
||||
>
|
||||
{exam.title[handlers.locale]}
|
||||
{row.original.title[locale]}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'certification',
|
||||
header: t('exam.columns.certification'),
|
||||
render: (exam) => <Text fz="sm">{handlers.getCertName(exam.certificationId)}</Text>,
|
||||
header: t("exam.columns.certification"),
|
||||
cell: ({ row }) => <Text fz="sm">{getCertName(row.original.certificationId)}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'date',
|
||||
header: t('exam.columns.date'),
|
||||
render: (exam) => <Text fz="sm">{exam.date}</Text>,
|
||||
header: t("exam.columns.date"),
|
||||
cell: ({ row }) => <Text fz="sm">{row.original.date}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'type',
|
||||
header: t('exam.columns.type'),
|
||||
render: (exam) => (
|
||||
<Badge size="sm" variant="light" color={exam.type === 'WRITTEN' ? 'blue' : 'orange'}>
|
||||
{t(`exam.type.${exam.type}`)}
|
||||
header: t("exam.columns.type"),
|
||||
cell: ({ row }) => (
|
||||
<Badge size="sm" variant="light" color={row.original.type === "WRITTEN" ? "blue" : "orange"}>
|
||||
{t(`exam.type.${row.original.type}`)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'form',
|
||||
header: t('exam.columns.form'),
|
||||
render: (exam) => (
|
||||
<Badge size="sm" variant="light" color={exam.form === 'ESSAY' ? 'blue' : 'violet'}>
|
||||
{t(`exam.formType.${exam.form}`)}
|
||||
header: t("exam.columns.form"),
|
||||
cell: ({ row }) => (
|
||||
<Badge size="sm" variant="light" color={row.original.form === "ESSAY" ? "blue" : "violet"}>
|
||||
{t(`exam.formType.${row.original.form}`)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'venue',
|
||||
header: t('exam.columns.venue'),
|
||||
render: (exam) => <Text fz="sm">{exam.venue}</Text>,
|
||||
header: t("exam.columns.venue"),
|
||||
cell: ({ row }) => <Text fz="sm">{row.original.venue}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'questions',
|
||||
header: t('exam.columns.questions'),
|
||||
render: (exam) => (
|
||||
header: t("exam.columns.questions"),
|
||||
cell: ({ row }) => (
|
||||
<Badge size="sm" variant="light" color="gray">
|
||||
{exam.questions?.length ?? 0}
|
||||
{row.original.questions?.length ?? 0}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: t('exam.columns.status'),
|
||||
render: (exam) => (
|
||||
<Badge size="sm" variant="light" color={STATUS_COLOR[exam.status]}>
|
||||
{t(`exam.status.${exam.status}`)}
|
||||
header: t("exam.columns.status"),
|
||||
cell: ({ row }) => (
|
||||
<Badge size="sm" variant="light" color={STATUS_COLOR[row.original.status]}>
|
||||
{t(`exam.status.${row.original.status}`)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Stack,
|
||||
Title,
|
||||
@@ -9,30 +9,31 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
Paper,
|
||||
Loader,
|
||||
Center,
|
||||
Card,
|
||||
Alert,
|
||||
Select,
|
||||
NumberInput,
|
||||
Tabs,
|
||||
SimpleGrid,
|
||||
Divider,
|
||||
} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IconPlus, IconInfoCircle, IconCalendar, IconClipboardList } from '@tabler/icons-react';
|
||||
import { AdvancedTable, notify } from '@ema-platform/ui';
|
||||
import { useGetCertificationsQuery } from '../../../certification/api/certification-api';
|
||||
} from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
IconPlus,
|
||||
IconInfoCircle,
|
||||
IconClipboardList,
|
||||
} from "@tabler/icons-react";
|
||||
import { notify, useErrorHandler, AdvancedTable, useServerTable, ModalFooter, AmharicDatePicker } from "@ema-platform/ui";
|
||||
import { useGetCertificationsQuery } from "../../../certification/api/certification-api";
|
||||
import {
|
||||
useGetExamsQuery,
|
||||
useCreateExamMutation,
|
||||
useUpdateExamMutation,
|
||||
useDeleteExamMutation,
|
||||
} from '../../api/exam-api';
|
||||
import type { Exam } from '../../types/exam';
|
||||
import { examColumns } from './columns';
|
||||
import { examColumnActions } from './actions';
|
||||
} from "../../api/exam-api";
|
||||
import type { Exam } from "../../types/exam";
|
||||
import { examColumns } from "./columns";
|
||||
import { examActionsColumn } from "./actions";
|
||||
|
||||
function ExamForm({
|
||||
editing,
|
||||
@@ -48,100 +49,297 @@ function ExamForm({
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
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 [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 [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');
|
||||
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);
|
||||
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">
|
||||
<Modal opened onClose={onCancel} title={editing ? t("exam.update") : t("exam.add")} size="xl">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Tabs defaultValue="basic" variant="outline" radius="md">
|
||||
<Tabs.List mb="md">
|
||||
<Tabs.Tab value="basic" leftSection={<IconInfoCircle size={15} />}>{t('exam.form.basicInfo')}</Tabs.Tab>
|
||||
<Tabs.Tab value="settings" leftSection={<IconClipboardList size={15} />}>{t('exam.form.settings')}</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
<Tabs defaultValue="basic" variant="outline" radius="md">
|
||||
<Tabs.List mb="md">
|
||||
<Tabs.Tab value="basic" leftSection={<IconInfoCircle size={15} />}>
|
||||
{t("exam.form.basicInfo")}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab
|
||||
value="settings"
|
||||
leftSection={<IconClipboardList size={15} />}
|
||||
>
|
||||
{t("exam.form.settings")}
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="basic">
|
||||
<Stack gap="sm">
|
||||
<Select label={t('exam.form.certification')} placeholder={t('exam.form.selectCertification')} data={certOptions} value={certificationId} onChange={setCertificationId} size="sm" searchable required />
|
||||
<TextInput label={t('exam.form.titleEn')} placeholder={t('exam.form.titleEnPlaceholder')} value={titleEn} onChange={(e) => setTitleEn(e.currentTarget.value)} size="sm" required />
|
||||
<TextInput label={t('exam.form.titleAm')} placeholder={t('exam.form.titleAmPlaceholder')} value={titleAm} onChange={(e) => setTitleAm(e.currentTarget.value)} size="sm" required />
|
||||
<Textarea label={t('exam.form.directionEn')} placeholder={t('exam.form.directionEnPlaceholder')} value={directionEn} onChange={(e) => setDirectionEn(e.currentTarget.value)} size="sm" autosize minRows={2} />
|
||||
<Textarea label={t('exam.form.directionAm')} placeholder={t('exam.form.directionAmPlaceholder')} value={directionAm} onChange={(e) => setDirectionAm(e.currentTarget.value)} size="sm" autosize minRows={2} />
|
||||
<TextInput label={t('exam.form.examDate')} type="date" value={date} onChange={(e) => setDate(e.currentTarget.value)} size="sm" leftSection={<IconCalendar size={14} />} required />
|
||||
<TextInput label={t('exam.form.venue')} placeholder={t('exam.form.venuePlaceholder')} value={venue} onChange={(e) => setVenue(e.currentTarget.value)} size="sm" required />
|
||||
<Tabs.Panel value="basic">
|
||||
<Stack gap="sm">
|
||||
<Select
|
||||
label={t("exam.form.certification")}
|
||||
placeholder={t("exam.form.selectCertification")}
|
||||
data={certOptions}
|
||||
value={certificationId}
|
||||
onChange={setCertificationId}
|
||||
size="sm"
|
||||
searchable
|
||||
required
|
||||
/>
|
||||
<TextInput
|
||||
label={t("exam.form.titleEn")}
|
||||
placeholder={t("exam.form.titleEnPlaceholder")}
|
||||
value={titleEn}
|
||||
onChange={(e) => setTitleEn(e.currentTarget.value)}
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
<TextInput
|
||||
label={t("exam.form.titleAm")}
|
||||
placeholder={t("exam.form.titleAmPlaceholder")}
|
||||
value={titleAm}
|
||||
onChange={(e) => setTitleAm(e.currentTarget.value)}
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
<Textarea
|
||||
label={t("exam.form.directionEn")}
|
||||
placeholder={t("exam.form.directionEnPlaceholder")}
|
||||
value={directionEn}
|
||||
onChange={(e) => setDirectionEn(e.currentTarget.value)}
|
||||
size="sm"
|
||||
autosize
|
||||
minRows={2}
|
||||
/>
|
||||
<Textarea
|
||||
label={t("exam.form.directionAm")}
|
||||
placeholder={t("exam.form.directionAmPlaceholder")}
|
||||
value={directionAm}
|
||||
onChange={(e) => setDirectionAm(e.currentTarget.value)}
|
||||
size="sm"
|
||||
autosize
|
||||
minRows={2}
|
||||
/>
|
||||
<AmharicDatePicker
|
||||
label={t("exam.form.examDate")}
|
||||
value={date}
|
||||
onChange={setDate}
|
||||
dateFormat="date"
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
<TextInput
|
||||
label={t("exam.form.venue")}
|
||||
placeholder={t("exam.form.venuePlaceholder")}
|
||||
value={venue}
|
||||
onChange={(e) => setVenue(e.currentTarget.value)}
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
|
||||
<Text fz="sm" fw={500}>{t('exam.form.timeAllowed')}</Text>
|
||||
<Group gap="sm" grow>
|
||||
<NumberInput label={t('exam.form.days')} value={days} onChange={(v) => setDays(Number(v))} min={0} size="sm" />
|
||||
<NumberInput label={t('exam.form.hours')} value={hours} onChange={(v) => setHours(Number(v))} min={0} size="sm" />
|
||||
<NumberInput label={t('exam.form.minutes')} value={minutes} onChange={(v) => setMinutes(Number(v))} min={0} size="sm" />
|
||||
</Group>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
<Text fz="sm" fw={500}>
|
||||
{t("exam.form.timeAllowed")}
|
||||
</Text>
|
||||
<Group gap="sm" grow>
|
||||
<NumberInput
|
||||
label={t("exam.form.days")}
|
||||
value={days}
|
||||
onChange={(v) => setDays(Number(v))}
|
||||
min={0}
|
||||
size="sm"
|
||||
/>
|
||||
<NumberInput
|
||||
label={t("exam.form.hours")}
|
||||
value={hours}
|
||||
onChange={(v) => setHours(Number(v))}
|
||||
min={0}
|
||||
size="sm"
|
||||
/>
|
||||
<NumberInput
|
||||
label={t("exam.form.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={t('exam.columns.type')} placeholder="Written or Oral" data={[{ value: 'WRITTEN', label: t('exam.form.written') }, { value: 'ORAL', label: t('exam.form.oral') }]} value={type} onChange={setType} size="sm" required />
|
||||
<Select label={t('exam.columns.form')} placeholder="Essay or Choice" data={[{ value: 'ESSAY', label: t('exam.form.essay') }, { value: 'CHOICE', label: t('exam.form.choice') }]} value={form} onChange={setForm} size="sm" required />
|
||||
<Select label={t('exam.detail.administration')} placeholder="Offline or Online" data={[{ value: 'OFFLINE', label: t('exam.form.offline') }, { value: 'ONLINE', label: t('exam.form.online') }]} value={adminMethod} onChange={setAdminMethod} size="sm" required />
|
||||
<Select label={t('exam.detail.evaluation')} placeholder="How to compute score" data={[{ value: 'SUM', label: t('exam.form.sum') }, { value: 'AVERAGE', label: t('exam.form.average') }, { value: 'PERCENTAGE', label: t('exam.form.percentage') }]} value={evalMethod} onChange={setEvalMethod} size="sm" required />
|
||||
<Select label={t('exam.detail.selection')} placeholder="Manual or Random" data={[{ value: 'MANUAL', label: t('exam.form.manual') }, { value: 'RANDOM', label: t('exam.form.random') }]} value={selMethod} onChange={setSelMethod} size="sm" />
|
||||
<NumberInput label={t('exam.form.cuttingPoint')} placeholder={t('exam.form.cuttingPointPlaceholder')} value={cuttingPoint} onChange={(v) => setCuttingPoint(Number(v))} min={0} size="sm" required />
|
||||
</SimpleGrid>
|
||||
{editing && (
|
||||
<Select label={t('exam.form.status')} placeholder={t('exam.form.statusPlaceholder')} data={[
|
||||
{ value: 'PENDING', label: t('exam.form.pending') }, { value: 'ACTIVE', label: t('exam.form.active') },
|
||||
{ value: 'COMPLETED', label: t('exam.form.completed') }, { value: 'CANCELLED', label: t('exam.form.cancelled') },
|
||||
{ value: 'POSTPONED', label: t('exam.form.postponed') }, { value: 'PUBLISHED', label: t('exam.form.published') },
|
||||
]} value={status} onChange={setStatus} size="sm" />
|
||||
)}
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
<Tabs.Panel value="settings">
|
||||
<Stack gap="sm">
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<Select
|
||||
label={t("exam.columns.type")}
|
||||
placeholder="Written or Oral"
|
||||
data={[
|
||||
{ value: "WRITTEN", label: t("exam.form.written") },
|
||||
{ value: "ORAL", label: t("exam.form.oral") },
|
||||
]}
|
||||
value={type}
|
||||
onChange={setType}
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
<Select
|
||||
label={t("exam.columns.form")}
|
||||
placeholder="Essay or Choice"
|
||||
data={[
|
||||
{ value: "ESSAY", label: t("exam.form.essay") },
|
||||
{ value: "CHOICE", label: t("exam.form.choice") },
|
||||
]}
|
||||
value={form}
|
||||
onChange={setForm}
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
<Select
|
||||
label={t("exam.detail.administration")}
|
||||
placeholder="Offline or Online"
|
||||
data={[
|
||||
{ value: "OFFLINE", label: t("exam.form.offline") },
|
||||
{ value: "ONLINE", label: t("exam.form.online") },
|
||||
]}
|
||||
value={adminMethod}
|
||||
onChange={setAdminMethod}
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
<Select
|
||||
label={t("exam.detail.evaluation")}
|
||||
placeholder="How to compute score"
|
||||
data={[
|
||||
{ value: "SUM", label: t("exam.form.sum") },
|
||||
{ value: "AVERAGE", label: t("exam.form.average") },
|
||||
{ value: "PERCENTAGE", label: t("exam.form.percentage") },
|
||||
]}
|
||||
value={evalMethod}
|
||||
onChange={setEvalMethod}
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
<Select
|
||||
label={t("exam.detail.selection")}
|
||||
placeholder="Manual or Random"
|
||||
data={[
|
||||
{ value: "MANUAL", label: t("exam.form.manual") },
|
||||
{ value: "RANDOM", label: t("exam.form.random") },
|
||||
]}
|
||||
value={selMethod}
|
||||
onChange={setSelMethod}
|
||||
size="sm"
|
||||
/>
|
||||
<NumberInput
|
||||
label={t("exam.form.cuttingPoint")}
|
||||
placeholder={t("exam.form.cuttingPointPlaceholder")}
|
||||
value={cuttingPoint}
|
||||
onChange={(v) => setCuttingPoint(Number(v))}
|
||||
min={0}
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
</SimpleGrid>
|
||||
{editing && (
|
||||
<Select
|
||||
label={t("exam.form.status")}
|
||||
placeholder={t("exam.form.statusPlaceholder")}
|
||||
data={[
|
||||
{ value: "PENDING", label: t("exam.form.pending") },
|
||||
{ value: "ACTIVE", label: t("exam.form.active") },
|
||||
{ value: "COMPLETED", label: t("exam.form.completed") },
|
||||
{ value: "CANCELLED", label: t("exam.form.cancelled") },
|
||||
{ value: "POSTPONED", label: t("exam.form.postponed") },
|
||||
{ value: "PUBLISHED", label: t("exam.form.published") },
|
||||
]}
|
||||
value={status}
|
||||
onChange={setStatus}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button variant="default" onClick={onCancel} size="sm">{t('exam.cancel')}</Button>
|
||||
<Button type="submit" size="sm" loading={isSubmitting}>{editing ? t('exam.update') : t('exam.create')}</Button>
|
||||
</Group>
|
||||
<ModalFooter mt="md">
|
||||
<Button variant="default" onClick={onCancel} size="sm">
|
||||
{t("exam.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" size="sm" loading={isSubmitting}>
|
||||
{editing ? t("exam.update") : t("exam.create")}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
</Paper>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExamPage() {
|
||||
const navigate = useNavigate();
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.language as 'en' | 'am';
|
||||
const { handleError } = useErrorHandler();
|
||||
const locale = i18n.language as "en" | "am";
|
||||
const { data: certRes } = useGetCertificationsQuery();
|
||||
const { data, isLoading, isError, refetch } = useGetExamsQuery();
|
||||
const { data, isFetching, isError, refetch } = useGetExamsQuery();
|
||||
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable();
|
||||
const [createExam, { isLoading: isCreating }] = useCreateExamMutation();
|
||||
const [updateExam, { isLoading: isUpdating }] = useUpdateExamMutation();
|
||||
const [deleteExam] = useDeleteExamMutation();
|
||||
@@ -152,26 +350,40 @@ export function ExamPage() {
|
||||
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 [deleteOpened, { open: openDelete, close: closeDelete }] =
|
||||
useDisclosure(false);
|
||||
|
||||
const certOptions = certifications.filter((c) => c.isActive).map((c) => ({ value: c.id, label: c.name[locale] }));
|
||||
const getCertName = (id: string) => certifications.find((c) => c.id === id)?.name?.[locale] ?? '-';
|
||||
const certOptions = certifications
|
||||
.filter((c) => c.isActive)
|
||||
.map((c) => ({ value: c.id, label: c.name[locale] }));
|
||||
const getCertName = (id: string) =>
|
||||
certifications.find((c) => c.id === id)?.name?.[locale] ?? "-";
|
||||
|
||||
const resetForm = () => { setEditing(null); setShowForm(false); };
|
||||
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,
|
||||
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 },
|
||||
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',
|
||||
selectionMethod: values.selMethod || "MANUAL",
|
||||
cuttingPoint: values.cuttingPoint,
|
||||
};
|
||||
if (isEdit) payload.status = values.status;
|
||||
@@ -179,14 +391,14 @@ export function ExamPage() {
|
||||
try {
|
||||
if (isEdit && editing) {
|
||||
await updateExam({ id: editing.id, ...payload }).unwrap();
|
||||
notify.success(t('exam.updated'));
|
||||
notify.success(t("exam.updated"));
|
||||
} else {
|
||||
await createExam(payload).unwrap();
|
||||
notify.success(t('exam.created'));
|
||||
notify.success(t("exam.created"));
|
||||
}
|
||||
resetForm();
|
||||
} catch {
|
||||
notify.error(t('exam.error'));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -194,27 +406,57 @@ export function ExamPage() {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
await deleteExam(deleteTarget.id).unwrap();
|
||||
notify.success(t('exam.deleted'));
|
||||
notify.success(t("exam.deleted"));
|
||||
closeDelete();
|
||||
setDeleteTarget(null);
|
||||
} catch {
|
||||
notify.error(t('exam.error'));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) return <Center py="xl"><Loader /></Center>;
|
||||
if (isError) return <Alert icon={<IconInfoCircle size={16} />} color="red" title={t('exam.loadError')} />;
|
||||
if (isError)
|
||||
return (
|
||||
<Alert
|
||||
icon={<IconInfoCircle size={16} />}
|
||||
color="red"
|
||||
title={t("exam.loadError")}
|
||||
/>
|
||||
);
|
||||
|
||||
const columns = [
|
||||
...examColumns(t, locale, getCertName, (exam) => navigate(`/exams/${exam.id}`)),
|
||||
examActionsColumn(t, {
|
||||
onEdit: (exam) => {
|
||||
setEditing(exam);
|
||||
setShowForm(true);
|
||||
},
|
||||
onDelete: (exam) => {
|
||||
setDeleteTarget(exam);
|
||||
openDelete();
|
||||
},
|
||||
onDetails: (exam) => navigate(`/exams/${exam.id}`),
|
||||
}),
|
||||
];
|
||||
|
||||
const page = paginate(exams);
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="flex-end">
|
||||
<div>
|
||||
<Title order={2}>{t('exam.title')}</Title>
|
||||
<Text fz="sm" c="dimmed">{t('exam.subtitle')}</Text>
|
||||
<Title order={2}>{t("exam.title")}</Title>
|
||||
<Text fz="sm" c="dimmed">
|
||||
{t("exam.subtitle")}
|
||||
</Text>
|
||||
</div>
|
||||
{!showForm && (
|
||||
<Button variant="light" leftSection={<IconPlus size={16} />} onClick={() => setShowForm(true)} size="sm">
|
||||
{t('exam.add')}
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<IconPlus size={16} />}
|
||||
onClick={() => setShowForm(true)}
|
||||
size="sm"
|
||||
>
|
||||
{t("exam.add")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
@@ -229,31 +471,42 @@ export function ExamPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<Paper withBorder radius="md">
|
||||
<Card withBorder padding={0}>
|
||||
<AdvancedTable
|
||||
columns={examColumns(t, {
|
||||
locale,
|
||||
getCertName,
|
||||
onTitleClick: (exam) => navigate(`/exams/${exam.id}`),
|
||||
})}
|
||||
data={exams}
|
||||
rowKey={(exam) => exam.id}
|
||||
actions={examColumnActions(t, {
|
||||
onEdit: (exam) => { setEditing(exam); setShowForm(true); },
|
||||
onDelete: (exam) => { setDeleteTarget(exam); openDelete(); },
|
||||
})}
|
||||
onRefresh={refetch}
|
||||
emptyTitle={t('exam.noItems')}
|
||||
columns={columns}
|
||||
data={page.rows}
|
||||
tableName={t("exam.title")}
|
||||
itemCount={page.itemCount}
|
||||
pageIndex={page.pageIndex}
|
||||
onPageChange={setPageIndex}
|
||||
pageSize={pageSize}
|
||||
onPageSizeChange={setPageSize}
|
||||
refresh={refetch}
|
||||
isLoading={isFetching}
|
||||
emptyText={t("exam.noItems")}
|
||||
/>
|
||||
</Paper>
|
||||
</Card>
|
||||
|
||||
{/* Delete confirmation */}
|
||||
<Modal opened={deleteOpened} onClose={closeDelete} title={t('exam.confirmDelete')} size="sm">
|
||||
<Text mb="md">{t('exam.deleteConfirmText', { name: deleteTarget?.title?.[locale] ?? '' })}</Text>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={closeDelete} size="sm">{t('exam.cancel')}</Button>
|
||||
<Button color="red" onClick={handleDelete} size="sm">{t('exam.delete')}</Button>
|
||||
</Group>
|
||||
<Modal
|
||||
opened={deleteOpened}
|
||||
onClose={closeDelete}
|
||||
title={t("exam.confirmDelete")}
|
||||
size="sm"
|
||||
>
|
||||
<Text mb="md">
|
||||
{t("exam.deleteConfirmText", {
|
||||
name: deleteTarget?.title?.[locale] ?? "",
|
||||
})}
|
||||
</Text>
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={closeDelete} size="sm">
|
||||
{t("exam.cancel")}
|
||||
</Button>
|
||||
<Button color="red" onClick={handleDelete} size="sm">
|
||||
{t("exam.delete")}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import type { LocalePair } from '../../certification/types/certification';
|
||||
import type { EstimatedTime } from '../../question/types/question';
|
||||
import type { QuestionForm } from '../../question/types/question';
|
||||
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 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;
|
||||
|
||||
Reference in New Issue
Block a user