mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-30 06:28:13 +00:00
- Added `useErrorHandler` hook to centralize error handling logic. - Updated components in the backoffice and portal applications to utilize the new error handling mechanism, replacing direct notify calls with `handleError` for improved error messaging. - Enhanced localization files to include generic error messages for better user feedback. - Refactored error handling in forms and API interactions to ensure consistent user experience across the application.
506 lines
19 KiB
TypeScript
506 lines
19 KiB
TypeScript
import { useState, useCallback, type ElementType } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import {
|
|
Stack,
|
|
Title,
|
|
Group,
|
|
Table,
|
|
Badge,
|
|
Modal,
|
|
Text,
|
|
Paper,
|
|
Loader,
|
|
Center,
|
|
Alert,
|
|
Select,
|
|
SimpleGrid,
|
|
Divider,
|
|
Button,
|
|
ThemeIcon,
|
|
Box,
|
|
TextInput,
|
|
} from '@mantine/core';
|
|
import { useDisclosure } from '@mantine/hooks';
|
|
import {
|
|
IconInfoCircle,
|
|
IconEye,
|
|
IconTrash,
|
|
IconUser,
|
|
IconCertificate,
|
|
IconDeviceFloppy,
|
|
IconPlus,
|
|
IconClipboardList,
|
|
IconCircleCheck,
|
|
IconCircleX,
|
|
IconChartBar,
|
|
IconSearch,
|
|
} from '@tabler/icons-react';
|
|
import { notify, BilingualInput, useErrorHandler } from '@ema-platform/ui';
|
|
import type { BilingualValue } from '@ema-platform/ui';
|
|
import { useGetResultsQuery, useLazyGetResultQuery, useDeleteResultMutation, useUpdateResultMutation } from '../api/result-api';
|
|
import { useGetExamsQuery } from '../../exam/api/exam-api';
|
|
import { RecordResultModal } from '../components/RecordResultModal';
|
|
import type { Result, ResultBreakdown } from '../types/result';
|
|
import type { Exam } from '../../exam/types/exam';
|
|
|
|
const STATUS_COLOR: Record<string, string> = {
|
|
PASSED: 'teal',
|
|
FAILED: 'red',
|
|
};
|
|
|
|
function ResultStat({
|
|
label,
|
|
value,
|
|
sub,
|
|
icon: Icon,
|
|
color,
|
|
}: {
|
|
label: string;
|
|
value: string;
|
|
sub?: string;
|
|
icon: ElementType;
|
|
color: string;
|
|
}) {
|
|
return (
|
|
<Paper p="lg" radius="lg" withBorder>
|
|
<Group justify="space-between" align="flex-start">
|
|
<ThemeIcon size={46} radius="md" variant="light" color={color}>
|
|
<Icon size={22} />
|
|
</ThemeIcon>
|
|
{sub && (
|
|
<Badge variant="light" color={color} radius="sm">
|
|
{sub}
|
|
</Badge>
|
|
)}
|
|
</Group>
|
|
<Text fz={30} fw={800} mt="md" lh={1.1}>
|
|
{value}
|
|
</Text>
|
|
<Text size="sm" c="dimmed" mt={4}>
|
|
{label}
|
|
</Text>
|
|
</Paper>
|
|
);
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|
|
|
|
export function ResultPage() {
|
|
const { t, i18n } = useTranslation();
|
|
const locale = i18n.language as 'en' | 'am';
|
|
const { handleError } = useErrorHandler();
|
|
const { data: examRes } = useGetExamsQuery();
|
|
const { data, isLoading, isError } = useGetResultsQuery();
|
|
const [fetchDetail, { data: detailResult, isFetching: isDetailLoading }] = useLazyGetResultQuery();
|
|
|
|
const exams = examRes?.items ?? [];
|
|
const results = data?.items ?? [];
|
|
|
|
const [deleteResult] = useDeleteResultMutation();
|
|
const [updateResult] = useUpdateResultMutation();
|
|
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [examFilter, setExamFilter] = useState<string | null>(null);
|
|
const [detailOpened, { open: openDetail, close: closeDetail }] = useDisclosure(false);
|
|
const [deleteTarget, setDeleteTarget] = useState<Result | null>(null);
|
|
const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false);
|
|
const [detailStatus, setDetailStatus] = useState<string>('PASSED');
|
|
const [detailRemark, setDetailRemark] = useState<BilingualValue>({ en: '', am: '' });
|
|
const [detailBreakdowns, setDetailBreakdowns] = useState<ResultBreakdown[]>([]);
|
|
const [detailSaving, setDetailSaving] = useState(false);
|
|
const [pickerExamId, setPickerExamId] = useState<string | null>(null);
|
|
const [pickerOpened, { open: openPicker, close: closePicker }] = useDisclosure(false);
|
|
const [recordExam, setRecordExam] = useState<Exam | null>(null);
|
|
const [recordOpened, { open: openRecord, close: closeRecord }] = useDisclosure(false);
|
|
|
|
const examOptions = exams.map((e) => ({ value: e.id, label: `${e.title.en} (${e.date})` }));
|
|
|
|
const startRecord = () => {
|
|
const ex = exams.find((e) => e.id === pickerExamId);
|
|
if (!ex) {
|
|
notify.error(t('result.selectExamError'));
|
|
return;
|
|
}
|
|
setRecordExam(ex);
|
|
closePicker();
|
|
openRecord();
|
|
};
|
|
|
|
const handleRecordClose = () => {
|
|
closeRecord();
|
|
setRecordExam(null);
|
|
setPickerExamId(null);
|
|
};
|
|
|
|
const filtered = results.filter((r) => {
|
|
if (examFilter && r.examId !== examFilter) return false;
|
|
if (!searchQuery) return true;
|
|
const q = searchQuery.toLowerCase();
|
|
if (r.seafarerId.toLowerCase().includes(q)) return true;
|
|
if (r.seafarer) {
|
|
const name = `${r.seafarer.firstName} ${r.seafarer.middleName ?? ''} ${r.seafarer.lastName}`.toLowerCase();
|
|
if (name.includes(q)) return true;
|
|
}
|
|
return false;
|
|
});
|
|
|
|
const total = results.length;
|
|
const passedCount = results.filter((r) => r.status === 'PASSED').length;
|
|
const failedCount = total - passedCount;
|
|
const passRate = total ? Math.round((passedCount / total) * 100) : 0;
|
|
const avgScore = total
|
|
? (results.reduce((s, r) => s + Number(r.totalScore || 0), 0) / total).toFixed(1)
|
|
: '0';
|
|
|
|
const getExamTitle = (id: string) => exams.find((e) => e.id === id)?.title?.[locale] ?? '-';
|
|
|
|
const viewDetail = useCallback((result: Result) => {
|
|
fetchDetail(result.id);
|
|
setDetailStatus(result.status);
|
|
setDetailRemark({ en: result.remark?.en ?? '', am: result.remark?.am ?? '' });
|
|
setDetailBreakdowns(result.resultBreakdowns.map((b) => ({ ...b })));
|
|
openDetail();
|
|
}, [fetchDetail, openDetail]);
|
|
|
|
const handleDetailSave = async () => {
|
|
if (!detailResult) return;
|
|
setDetailSaving(true);
|
|
try {
|
|
await updateResult({
|
|
id: detailResult.id,
|
|
status: detailStatus as 'PASSED' | 'FAILED',
|
|
remark: detailRemark.en || detailRemark.am ? detailRemark : undefined,
|
|
resultBreakdowns: detailBreakdowns,
|
|
}).unwrap();
|
|
notify.success(t('result.updated'));
|
|
closeDetail();
|
|
} catch (e) {
|
|
handleError(e);
|
|
} finally {
|
|
setDetailSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleDetailClose = () => {
|
|
closeDetail();
|
|
setDetailBreakdowns([]);
|
|
setDetailRemark({ en: '', am: '' });
|
|
setDetailStatus('PASSED');
|
|
};
|
|
|
|
const handleDelete = async () => {
|
|
if (!deleteTarget) return;
|
|
try {
|
|
await deleteResult(deleteTarget.id).unwrap();
|
|
notify.success(t('result.deleted'));
|
|
closeDelete();
|
|
setDeleteTarget(null);
|
|
} catch (e) {
|
|
handleError(e);
|
|
}
|
|
};
|
|
|
|
if (isLoading) return <Center py="xl"><Loader /></Center>;
|
|
if (isError) return <Alert icon={<IconInfoCircle size={16} />} color="red" title={t('result.loadError')} />;
|
|
|
|
return (
|
|
<Stack gap="lg">
|
|
<Group justify="space-between" align="flex-end">
|
|
<div>
|
|
<Title order={2}>{t('result.title')}</Title>
|
|
<Text fz="sm" c="dimmed">{t('result.subtitle')}</Text>
|
|
</div>
|
|
<Button leftSection={<IconPlus size={15} />} onClick={openPicker} size="sm">
|
|
{t('result.record')}
|
|
</Button>
|
|
</Group>
|
|
|
|
<SimpleGrid cols={{ base: 2, lg: 4 }} spacing="lg">
|
|
<ResultStat label={t('result.stats.totalResults')} value={String(total)} icon={IconClipboardList} color="blue" />
|
|
<ResultStat label={t('result.stats.passed')} value={String(passedCount)} sub={`${passRate}%`} icon={IconCircleCheck} color="teal" />
|
|
<ResultStat label={t('result.stats.failed')} value={String(failedCount)} sub={`${total ? 100 - passRate : 0}%`} icon={IconCircleX} color="red" />
|
|
<ResultStat label={t('result.stats.avgScore')} value={avgScore} icon={IconChartBar} color="indigo" />
|
|
</SimpleGrid>
|
|
|
|
<Paper withBorder radius="md">
|
|
<Group p="md" justify="space-between" wrap="wrap" gap="sm">
|
|
<Text fw={600}>{t('result.section')}</Text>
|
|
<Group gap="sm" wrap="wrap">
|
|
<TextInput
|
|
placeholder={t('result.search.seafarer')}
|
|
leftSection={<IconSearch size={15} />}
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.currentTarget.value)}
|
|
size="sm"
|
|
style={{ width: 240 }}
|
|
/>
|
|
<Select
|
|
placeholder={t('result.search.filterByExam')}
|
|
data={[{ value: '', label: t('result.search.allExams') }, ...examOptions]}
|
|
value={examFilter}
|
|
onChange={(v) => setExamFilter(v ?? null)}
|
|
size="sm"
|
|
style={{ width: 280 }}
|
|
clearable
|
|
/>
|
|
</Group>
|
|
</Group>
|
|
|
|
<Table striped highlightOnHover>
|
|
<Table.Thead bg="var(--mantine-color-default-hover)">
|
|
<Table.Tr>
|
|
<Table.Th>{t('result.columns.seafarer')}</Table.Th>
|
|
<Table.Th>{t('result.columns.exam')}</Table.Th>
|
|
<Table.Th>{t('result.columns.totalScore')}</Table.Th>
|
|
<Table.Th>{t('result.columns.status')}</Table.Th>
|
|
<Table.Th>{t('result.columns.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[locale] : 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]}
|
|
leftSection={
|
|
<Box
|
|
w={6}
|
|
h={6}
|
|
style={{ borderRadius: 999, background: `var(--mantine-color-${STATUS_COLOR[r.status]}-6)` }}
|
|
/>
|
|
}
|
|
>
|
|
{t(`result.status.${r.status}`)}
|
|
</Badge>
|
|
</Table.Td>
|
|
<Table.Td><Text fz="sm">{new Date(r.createdAt).toLocaleDateString()}</Text></Table.Td>
|
|
<Table.Td>
|
|
<Group gap="xs">
|
|
<Button
|
|
size="xs"
|
|
variant="subtle"
|
|
leftSection={<IconEye size={13} />}
|
|
onClick={() => viewDetail(r)}
|
|
>
|
|
{t('result.action.viewEdit')}
|
|
</Button>
|
|
<Button
|
|
size="xs"
|
|
variant="subtle"
|
|
color="red"
|
|
leftSection={<IconTrash size={13} />}
|
|
onClick={() => { setDeleteTarget(r); openDelete(); }}
|
|
>
|
|
{t('result.action.delete')}
|
|
</Button>
|
|
</Group>
|
|
</Table.Td>
|
|
</Table.Tr>
|
|
))}
|
|
{filtered.length === 0 && (
|
|
<Table.Tr>
|
|
<Table.Td colSpan={6}>
|
|
<Text c="dimmed" ta="center" py="xl">{t('result.noItems')}</Text>
|
|
</Table.Td>
|
|
</Table.Tr>
|
|
)}
|
|
</Table.Tbody>
|
|
</Table>
|
|
</Paper>
|
|
|
|
<Modal
|
|
opened={detailOpened}
|
|
onClose={handleDetailClose}
|
|
title={t('result.detail.title')}
|
|
size="xl"
|
|
radius="lg"
|
|
>
|
|
{isDetailLoading ? (
|
|
<Center py="xl"><Loader /></Center>
|
|
) : detailResult ? (
|
|
<Stack gap="md">
|
|
{/* Seafarer Profile */}
|
|
<Paper withBorder p="md" radius="md">
|
|
<Group gap="sm" mb="sm">
|
|
<ThemeIcon size="sm" variant="light" color="blue" radius="xl">
|
|
<IconUser size={14} />
|
|
</ThemeIcon>
|
|
<Text fw={600} fz="sm">{t('result.detail.seafarerProfile')}</Text>
|
|
</Group>
|
|
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="sm">
|
|
<InfoRow label={t('result.detail.fullName')} value={detailResult.profile ? `${detailResult.profile.firstName} ${detailResult.profile.middleName ?? ''} ${detailResult.profile.lastName}` : detailResult.seafarerId} />
|
|
<InfoRow label={t('result.detail.gender')} value={detailResult.profile?.gender ?? '—'} />
|
|
<InfoRow label={t('result.detail.dateOfBirth')} value={detailResult.profile?.dob ? new Date(detailResult.profile.dob).toLocaleDateString() : '—'} />
|
|
<InfoRow label={t('result.detail.maritalStatus')} value={detailResult.profile?.maritalStatus ?? '—'} />
|
|
</SimpleGrid>
|
|
</Paper>
|
|
|
|
{/* Exam Details */}
|
|
{detailResult.exam && (
|
|
<Paper withBorder p="md" radius="md">
|
|
<Group gap="sm" mb="sm">
|
|
<ThemeIcon size="sm" variant="light" color="violet" radius="xl">
|
|
<IconCertificate size={14} />
|
|
</ThemeIcon>
|
|
<Text fw={600} fz="sm">{t('result.detail.examDetails')}</Text>
|
|
</Group>
|
|
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="sm">
|
|
<InfoRow label={t('result.detail.examTitle')} value={detailResult.exam.title?.[locale] ?? '—'} />
|
|
<InfoRow label={t('result.detail.type')} value={detailResult.exam.type ?? '—'} />
|
|
<InfoRow label={t('result.detail.venue')} value={detailResult.exam.venue ?? '—'} />
|
|
<InfoRow label={t('result.detail.date')} value={detailResult.exam.date ? new Date(detailResult.exam.date).toLocaleDateString() : '—'} />
|
|
<InfoRow label={t('result.detail.passMark')} value={String(detailResult.exam.cuttingPoint ?? 0)} />
|
|
</SimpleGrid>
|
|
</Paper>
|
|
)}
|
|
|
|
<Divider />
|
|
|
|
{/* Editable fields */}
|
|
<Select
|
|
label={t('result.detail.status')}
|
|
data={[
|
|
{ value: 'PASSED', label: t('result.status.PASSED') },
|
|
{ value: 'FAILED', label: t('result.status.FAILED') },
|
|
]}
|
|
value={detailStatus}
|
|
onChange={(v) => setDetailStatus(v ?? 'PASSED')}
|
|
size="sm"
|
|
/>
|
|
|
|
<BilingualInput
|
|
label={t('result.detail.remark')}
|
|
placeholder={{ en: 'Officer remark in English', am: 'የኃላፊ አስተያየት በአማርኛ' }}
|
|
value={detailRemark}
|
|
onChange={setDetailRemark}
|
|
size="sm"
|
|
/>
|
|
|
|
{detailBreakdowns.length > 0 && (
|
|
<>
|
|
<Text fw={600} fz="sm" tt="uppercase" c="gray.6">{t('result.detail.scoreBreakdown')}</Text>
|
|
<Table striped>
|
|
<Table.Thead>
|
|
<Table.Tr>
|
|
<Table.Th>#</Table.Th>
|
|
<Table.Th>{t('result.detail.question')}</Table.Th>
|
|
<Table.Th>{t('result.detail.max')}</Table.Th>
|
|
<Table.Th>{t('result.detail.score')}</Table.Th>
|
|
<Table.Th>{t('result.detail.remarkShort')}</Table.Th>
|
|
</Table.Tr>
|
|
</Table.Thead>
|
|
<Table.Tbody>
|
|
{detailBreakdowns.map((b, i) => {
|
|
const examDetail = exams.find((e) => e.id === detailResult.examId);
|
|
const q = examDetail?.questions?.find((eq) => eq.id === b.questionId);
|
|
return (
|
|
<Table.Tr key={b.questionId}>
|
|
<Table.Td><Text fz="xs">{i + 1}</Text></Table.Td>
|
|
<Table.Td>
|
|
<Text fz="xs" lineClamp={2} maw={200}>
|
|
{q?.title?.[locale] ?? b.questionId.slice(0, 8)}
|
|
</Text>
|
|
</Table.Td>
|
|
<Table.Td><Text fz="sm" fw={600}>{q?.points ?? '—'}</Text></Table.Td>
|
|
<Table.Td>
|
|
<TextInput
|
|
size="xs"
|
|
type="number"
|
|
style={{ width: 80 }}
|
|
value={b.score}
|
|
onChange={(e) => {
|
|
const updated = [...detailBreakdowns];
|
|
updated[i] = { ...updated[i], score: Number(e.currentTarget.value) };
|
|
setDetailBreakdowns(updated);
|
|
}}
|
|
/>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<TextInput
|
|
size="xs"
|
|
placeholder="Optional"
|
|
value={b.remark ?? ''}
|
|
onChange={(e) => {
|
|
const updated = [...detailBreakdowns];
|
|
updated[i] = { ...updated[i], remark: e.currentTarget.value || undefined };
|
|
setDetailBreakdowns(updated);
|
|
}}
|
|
/>
|
|
</Table.Td>
|
|
</Table.Tr>
|
|
);})}
|
|
</Table.Tbody>
|
|
</Table>
|
|
</>
|
|
)}
|
|
|
|
<Group justify="flex-end">
|
|
<Button variant="default" onClick={handleDetailClose} size="sm">{t('result.close')}</Button>
|
|
<Button
|
|
onClick={handleDetailSave}
|
|
size="sm"
|
|
loading={detailSaving}
|
|
leftSection={<IconDeviceFloppy size={15} />}
|
|
>
|
|
{t('result.save')}
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
) : (
|
|
<Text c="dimmed" ta="center" py="xl">{t('result.noData')}</Text>
|
|
)}
|
|
</Modal>
|
|
|
|
<Modal opened={deleteOpened} onClose={closeDelete} title={t('result.confirmDelete')} size="sm">
|
|
<Text mb="md">{t('result.deleteConfirmText')}</Text>
|
|
<Group justify="flex-end">
|
|
<Button variant="default" onClick={closeDelete} size="sm">{t('result.cancel')}</Button>
|
|
<Button color="red" onClick={handleDelete} size="sm">{t('result.delete')}</Button>
|
|
</Group>
|
|
</Modal>
|
|
|
|
{/* Choose exam, then record */}
|
|
<Modal opened={pickerOpened} onClose={closePicker} title={t('result.record')} size="md" radius="lg">
|
|
<Stack gap="md">
|
|
<Text fz="sm" c="dimmed">{t('result.detail.selectExam')}</Text>
|
|
<Select
|
|
label={t('result.detail.exam')}
|
|
placeholder={t('result.detail.selectExamPlaceholder')}
|
|
data={examOptions}
|
|
value={pickerExamId}
|
|
onChange={setPickerExamId}
|
|
size="sm"
|
|
searchable
|
|
required
|
|
/>
|
|
<Group justify="flex-end">
|
|
<Button variant="default" onClick={closePicker} size="sm">{t('result.cancel')}</Button>
|
|
<Button onClick={startRecord} size="sm" disabled={!pickerExamId}>{t('result.continue')}</Button>
|
|
</Group>
|
|
</Stack>
|
|
</Modal>
|
|
|
|
{recordExam && (
|
|
<RecordResultModal exam={recordExam} opened={recordOpened} onClose={handleRecordClose} />
|
|
)}
|
|
</Stack>
|
|
);
|
|
}
|