Files
emaui/apps/backoffice/src/app/features/result/pages/ResultPage.tsx
estifanos 8f7c163b9e feat: integrate error handling across various components
- 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.
2026-07-24 09:08:41 +00:00

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