mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
feat(i18n): add new translations for refresh, view, toggle columns, and no results
refactor(payment): remove payment API client and related tests, consolidating payment logic refactor(payment): delete payment modal and associated constants, hooks, and types fix(ui): enhance AdvancedTable with refresh button and column toggle functionality fix(ui): implement pagination in useServerTable for better data handling
This commit is contained in:
@@ -4,7 +4,6 @@ import {
|
|||||||
Title,
|
Title,
|
||||||
Group,
|
Group,
|
||||||
Button,
|
Button,
|
||||||
Table,
|
|
||||||
Badge,
|
Badge,
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Modal,
|
Modal,
|
||||||
@@ -12,14 +11,13 @@ import {
|
|||||||
TextInput,
|
TextInput,
|
||||||
Textarea,
|
Textarea,
|
||||||
Paper,
|
Paper,
|
||||||
Loader,
|
Card,
|
||||||
Center,
|
|
||||||
Alert,
|
Alert,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { IconEdit, IconTrash, IconPlus, IconInfoCircle, IconCertificate } from '@tabler/icons-react';
|
import { IconEdit, IconTrash, IconPlus, IconInfoCircle, IconCertificate } from '@tabler/icons-react';
|
||||||
import { notify, useErrorHandler } from '@ema-platform/ui';
|
import { notify, useErrorHandler, AdvancedTable, useServerTable, type AdvancedColumn } from '@ema-platform/ui';
|
||||||
import {
|
import {
|
||||||
useGetCertificationsQuery,
|
useGetCertificationsQuery,
|
||||||
useCreateCertificationMutation,
|
useCreateCertificationMutation,
|
||||||
@@ -76,7 +74,8 @@ export function CertificationPage() {
|
|||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const locale = i18n.language as 'en' | 'am';
|
const locale = i18n.language as 'en' | 'am';
|
||||||
const { handleError } = useErrorHandler();
|
const { handleError } = useErrorHandler();
|
||||||
const { data, isLoading, isError } = useGetCertificationsQuery();
|
const { data, isFetching, isError, refetch } = useGetCertificationsQuery();
|
||||||
|
const { setPageIndex, pageSize, paginate } = useServerTable({ pageSize: 25 });
|
||||||
const [createCert, { isLoading: isCreating }] = useCreateCertificationMutation();
|
const [createCert, { isLoading: isCreating }] = useCreateCertificationMutation();
|
||||||
const [updateCert, { isLoading: isUpdating }] = useUpdateCertificationMutation();
|
const [updateCert, { isLoading: isUpdating }] = useUpdateCertificationMutation();
|
||||||
const [deleteCert] = useDeleteCertificationMutation();
|
const [deleteCert] = useDeleteCertificationMutation();
|
||||||
@@ -122,9 +121,44 @@ export function CertificationPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading) return <Center py="xl"><Loader /></Center>;
|
|
||||||
if (isError) return <Alert icon={<IconInfoCircle size={16} />} color="red" title={t('certification.loadError')} />;
|
if (isError) return <Alert icon={<IconInfoCircle size={16} />} color="red" title={t('certification.loadError')} />;
|
||||||
|
|
||||||
|
const columns: AdvancedColumn<Certification>[] = [
|
||||||
|
{
|
||||||
|
header: t('certification.columns.name'),
|
||||||
|
cell: ({ row }) => <Text fz="sm" fw={500}>{row.original.name[locale]}</Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: t('certification.columns.description'),
|
||||||
|
cell: ({ row }) => <Text fz="sm" lineClamp={2} maw={250}>{row.original.description[locale]}</Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: t('certification.columns.status'),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Badge size="sm" variant="light" color={row.original.isActive ? 'teal' : 'gray'}>
|
||||||
|
{row.original.isActive ? t('certification.status.active') : t('certification.status.inactive')}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: '',
|
||||||
|
label: t('certification.columns.actions', 'Actions'),
|
||||||
|
size: 90,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Group gap="xs">
|
||||||
|
<ActionIcon variant="subtle" color="blue" size="sm" onClick={() => { setEditing(row.original); setShowForm(true); }}>
|
||||||
|
<IconEdit size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => { setDeleteTarget(row.original); openDelete(); }}>
|
||||||
|
<IconTrash size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const page = paginate(certifications);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<Group justify="space-between" align="flex-end">
|
<Group justify="space-between" align="flex-end">
|
||||||
@@ -148,50 +182,20 @@ export function CertificationPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Paper withBorder radius="md">
|
<Card withBorder padding={0}>
|
||||||
<Table striped highlightOnHover>
|
<AdvancedTable
|
||||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
columns={columns}
|
||||||
<Table.Tr>
|
data={page.rows}
|
||||||
<Table.Th>{t('certification.columns.name')}</Table.Th>
|
tableName={t('certification.title')}
|
||||||
<Table.Th>{t('certification.columns.description')}</Table.Th>
|
itemCount={page.itemCount}
|
||||||
<Table.Th>{t('certification.columns.status')}</Table.Th>
|
pageIndex={page.pageIndex}
|
||||||
<Table.Th />
|
onPageChange={setPageIndex}
|
||||||
</Table.Tr>
|
pageSize={pageSize}
|
||||||
</Table.Thead>
|
refresh={refetch}
|
||||||
<Table.Tbody>
|
isLoading={isFetching}
|
||||||
{certifications.map((cert) => (
|
emptyText={t('certification.noItems')}
|
||||||
<Table.Tr key={cert.id}>
|
/>
|
||||||
<Table.Td><Text fz="sm" fw={500}>{cert.name[locale]}</Text></Table.Td>
|
</Card>
|
||||||
<Table.Td>
|
|
||||||
<Text fz="sm" lineClamp={2} maw={250}>{cert.description[locale]}</Text>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Badge size="sm" variant="light" color={cert.isActive ? 'teal' : 'gray'}>
|
|
||||||
{cert.isActive ? t('certification.status.active') : t('certification.status.inactive')}
|
|
||||||
</Badge>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Group gap="xs">
|
|
||||||
<ActionIcon variant="subtle" color="blue" size="sm" onClick={() => { setEditing(cert); setShowForm(true); }}>
|
|
||||||
<IconEdit size={14} />
|
|
||||||
</ActionIcon>
|
|
||||||
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => { setDeleteTarget(cert); openDelete(); }}>
|
|
||||||
<IconTrash size={14} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Group>
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
))}
|
|
||||||
{certifications.length === 0 && (
|
|
||||||
<Table.Tr>
|
|
||||||
<Table.Td colSpan={4}>
|
|
||||||
<Text c="dimmed" ta="center" py="xl">{t('certification.noItems')}</Text>
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
)}
|
|
||||||
</Table.Tbody>
|
|
||||||
</Table>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
<Modal opened={deleteOpened} onClose={closeDelete} title={t('certification.confirmDelete')} size="sm">
|
<Modal opened={deleteOpened} onClose={closeDelete} title={t('certification.confirmDelete')} size="sm">
|
||||||
<Text mb="md">{t('certification.deleteConfirmText', { name: deleteTarget?.name?.[locale] })}</Text>
|
<Text mb="md">{t('certification.deleteConfirmText', { name: deleteTarget?.name?.[locale] })}</Text>
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
Title,
|
Title,
|
||||||
Group,
|
Group,
|
||||||
Button,
|
Button,
|
||||||
Table,
|
|
||||||
Badge,
|
Badge,
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Modal,
|
Modal,
|
||||||
@@ -13,8 +12,7 @@ import {
|
|||||||
TextInput,
|
TextInput,
|
||||||
Textarea,
|
Textarea,
|
||||||
Paper,
|
Paper,
|
||||||
Loader,
|
Card,
|
||||||
Center,
|
|
||||||
Alert,
|
Alert,
|
||||||
Select,
|
Select,
|
||||||
NumberInput,
|
NumberInput,
|
||||||
@@ -33,7 +31,7 @@ import {
|
|||||||
IconClipboardList,
|
IconClipboardList,
|
||||||
IconDetails,
|
IconDetails,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { notify, useErrorHandler } from "@ema-platform/ui";
|
import { notify, useErrorHandler, AdvancedTable, useServerTable, type AdvancedColumn } from "@ema-platform/ui";
|
||||||
import { useGetCertificationsQuery } from "../../certification/api/certification-api";
|
import { useGetCertificationsQuery } from "../../certification/api/certification-api";
|
||||||
import {
|
import {
|
||||||
useGetExamsQuery,
|
useGetExamsQuery,
|
||||||
@@ -356,7 +354,8 @@ export function ExamPage() {
|
|||||||
const { handleError } = useErrorHandler();
|
const { handleError } = useErrorHandler();
|
||||||
const locale = i18n.language as "en" | "am";
|
const locale = i18n.language as "en" | "am";
|
||||||
const { data: certRes } = useGetCertificationsQuery();
|
const { data: certRes } = useGetCertificationsQuery();
|
||||||
const { data, isLoading, isError } = useGetExamsQuery();
|
const { data, isFetching, isError, refetch } = useGetExamsQuery();
|
||||||
|
const { setPageIndex, pageSize, paginate } = useServerTable({ pageSize: 25 });
|
||||||
const [createExam, { isLoading: isCreating }] = useCreateExamMutation();
|
const [createExam, { isLoading: isCreating }] = useCreateExamMutation();
|
||||||
const [updateExam, { isLoading: isUpdating }] = useUpdateExamMutation();
|
const [updateExam, { isLoading: isUpdating }] = useUpdateExamMutation();
|
||||||
const [deleteExam] = useDeleteExamMutation();
|
const [deleteExam] = useDeleteExamMutation();
|
||||||
@@ -431,12 +430,6 @@ export function ExamPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading)
|
|
||||||
return (
|
|
||||||
<Center py="xl">
|
|
||||||
<Loader />
|
|
||||||
</Center>
|
|
||||||
);
|
|
||||||
if (isError)
|
if (isError)
|
||||||
return (
|
return (
|
||||||
<Alert
|
<Alert
|
||||||
@@ -446,6 +439,109 @@ export function ExamPage() {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const columns: AdvancedColumn<Exam>[] = [
|
||||||
|
{
|
||||||
|
header: t("exam.columns.title"),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Text
|
||||||
|
fz="sm"
|
||||||
|
fw={500}
|
||||||
|
c="blue"
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
onClick={() => navigate(`/exams/${row.original.id}`)}
|
||||||
|
>
|
||||||
|
{row.original.title[locale]}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: t("exam.columns.certification"),
|
||||||
|
cell: ({ row }) => <Text fz="sm">{getCertName(row.original.certificationId)}</Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: t("exam.columns.date"),
|
||||||
|
cell: ({ row }) => <Text fz="sm">{row.original.date}</Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: t("exam.columns.venue"),
|
||||||
|
cell: ({ row }) => <Text fz="sm">{row.original.venue}</Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: t("exam.columns.questions"),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Badge size="sm" variant="light" color="gray">
|
||||||
|
{row.original.questions?.length ?? 0}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: t("exam.columns.actions"),
|
||||||
|
align: "right",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Group gap="xs">
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="blue"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setEditing(row.original);
|
||||||
|
setShowForm(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconEdit size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="red"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setDeleteTarget(row.original);
|
||||||
|
openDelete();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconTrash size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="red"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
navigate(`/exams/${row.original.id}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconDetails size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const page = paginate(exams);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<Group justify="space-between" align="flex-end">
|
<Group justify="space-between" align="flex-end">
|
||||||
@@ -477,127 +573,20 @@ export function ExamPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Paper withBorder radius="md">
|
<Card withBorder padding={0}>
|
||||||
<Table striped highlightOnHover>
|
<AdvancedTable
|
||||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
columns={columns}
|
||||||
<Table.Tr>
|
data={page.rows}
|
||||||
<Table.Th>{t("exam.columns.title")}</Table.Th>
|
tableName={t("exam.title")}
|
||||||
<Table.Th>{t("exam.columns.certification")}</Table.Th>
|
itemCount={page.itemCount}
|
||||||
<Table.Th>{t("exam.columns.date")}</Table.Th>
|
pageIndex={page.pageIndex}
|
||||||
<Table.Th>{t("exam.columns.type")}</Table.Th>
|
onPageChange={setPageIndex}
|
||||||
<Table.Th>{t("exam.columns.form")}</Table.Th>
|
pageSize={pageSize}
|
||||||
<Table.Th>{t("exam.columns.venue")}</Table.Th>
|
refresh={refetch}
|
||||||
<Table.Th>{t("exam.columns.questions")}</Table.Th>
|
isLoading={isFetching}
|
||||||
<Table.Th>{t("exam.columns.status")}</Table.Th>
|
emptyText={t("exam.noItems")}
|
||||||
<Table.Th>{t("exam.columns.actions")}</Table.Th>
|
/>
|
||||||
<Table.Th />
|
</Card>
|
||||||
</Table.Tr>
|
|
||||||
</Table.Thead>
|
|
||||||
<Table.Tbody>
|
|
||||||
{exams.map((exam) => (
|
|
||||||
<Table.Tr key={exam.id}>
|
|
||||||
<Table.Td>
|
|
||||||
<Text
|
|
||||||
fz="sm"
|
|
||||||
fw={500}
|
|
||||||
c="blue"
|
|
||||||
style={{ cursor: "pointer" }}
|
|
||||||
onClick={() => navigate(`/exams/${exam.id}`)}
|
|
||||||
>
|
|
||||||
{exam.title[locale]}
|
|
||||||
</Text>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Text fz="sm">{getCertName(exam.certificationId)}</Text>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Text fz="sm">{exam.date}</Text>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Badge
|
|
||||||
size="sm"
|
|
||||||
variant="light"
|
|
||||||
color={exam.type === "WRITTEN" ? "blue" : "orange"}
|
|
||||||
>
|
|
||||||
{t(`exam.type.${exam.type}`)}
|
|
||||||
</Badge>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Badge
|
|
||||||
size="sm"
|
|
||||||
variant="light"
|
|
||||||
color={exam.form === "ESSAY" ? "blue" : "violet"}
|
|
||||||
>
|
|
||||||
{t(`exam.formType.${exam.form}`)}
|
|
||||||
</Badge>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Text fz="sm">{exam.venue}</Text>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Badge size="sm" variant="light" color="gray">
|
|
||||||
{exam.questions?.length ?? 0}
|
|
||||||
</Badge>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Badge
|
|
||||||
size="sm"
|
|
||||||
variant="light"
|
|
||||||
color={STATUS_COLOR[exam.status]}
|
|
||||||
>
|
|
||||||
{t(`exam.status.${exam.status}`)}
|
|
||||||
</Badge>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Group gap="xs">
|
|
||||||
<ActionIcon
|
|
||||||
variant="subtle"
|
|
||||||
color="blue"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
setEditing(exam);
|
|
||||||
setShowForm(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IconEdit size={14} />
|
|
||||||
</ActionIcon>
|
|
||||||
<ActionIcon
|
|
||||||
variant="subtle"
|
|
||||||
color="red"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
setDeleteTarget(exam);
|
|
||||||
openDelete();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IconTrash size={14} />
|
|
||||||
</ActionIcon>
|
|
||||||
<ActionIcon
|
|
||||||
variant="subtle"
|
|
||||||
color="red"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
navigate(`/exams/${exam.id}`);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IconDetails size={14} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Group>
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
))}
|
|
||||||
{exams.length === 0 && (
|
|
||||||
<Table.Tr>
|
|
||||||
<Table.Td colSpan={9}>
|
|
||||||
<Text c="dimmed" ta="center" py="xl">
|
|
||||||
{t("exam.noItems")}
|
|
||||||
</Text>
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
)}
|
|
||||||
</Table.Tbody>
|
|
||||||
</Table>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
{/* Delete confirmation */}
|
{/* Delete confirmation */}
|
||||||
<Modal
|
<Modal
|
||||||
|
|||||||
@@ -322,6 +322,7 @@ export function LicenseQueuePage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: sortableHeader(t('queue.number', 'App #'), 'applicationNumber'),
|
header: sortableHeader(t('queue.number', 'App #'), 'applicationNumber'),
|
||||||
|
label: t('queue.number', 'App #'),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Text size="sm" fw={500}>
|
<Text size="sm" fw={500}>
|
||||||
{row.original.applicationNumber}
|
{row.original.applicationNumber}
|
||||||
@@ -330,6 +331,7 @@ export function LicenseQueuePage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: sortableHeader(t('queue.company', 'Company'), 'companyName'),
|
header: sortableHeader(t('queue.company', 'Company'), 'companyName'),
|
||||||
|
label: t('queue.company', 'Company'),
|
||||||
cell: ({ row }) => <Text size="sm">{row.original.companyName ?? '—'}</Text>,
|
cell: ({ row }) => <Text size="sm">{row.original.companyName ?? '—'}</Text>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -346,6 +348,7 @@ export function LicenseQueuePage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: sortableHeader(t('queue.statusCol', 'Status'), 'status'),
|
header: sortableHeader(t('queue.statusCol', 'Status'), 'status'),
|
||||||
|
label: t('queue.statusCol', 'Status'),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Badge color={STATUS_COLORS[row.original.status]} variant="light">
|
<Badge color={STATUS_COLORS[row.original.status]} variant="light">
|
||||||
{STATUS_LABELS[row.original.status]}
|
{STATUS_LABELS[row.original.status]}
|
||||||
@@ -354,6 +357,7 @@ export function LicenseQueuePage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: sortableHeader(t('queue.submitted', 'Submitted'), 'submittedAt'),
|
header: sortableHeader(t('queue.submitted', 'Submitted'), 'submittedAt'),
|
||||||
|
label: t('queue.submitted', 'Submitted'),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
{row.original.submittedAt
|
{row.original.submittedAt
|
||||||
@@ -378,6 +382,7 @@ export function LicenseQueuePage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: '',
|
header: '',
|
||||||
|
label: t('queue.actionsColumn', 'Actions'),
|
||||||
align: 'right',
|
align: 'right',
|
||||||
size: 140,
|
size: 140,
|
||||||
cell: ({ row }) =>
|
cell: ({ row }) =>
|
||||||
|
|||||||
@@ -4,15 +4,13 @@ import {
|
|||||||
Title,
|
Title,
|
||||||
Group,
|
Group,
|
||||||
Button,
|
Button,
|
||||||
Table,
|
|
||||||
Badge,
|
Badge,
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Modal,
|
Modal,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
Paper,
|
Paper,
|
||||||
Loader,
|
Card,
|
||||||
Center,
|
|
||||||
Alert,
|
Alert,
|
||||||
Select,
|
Select,
|
||||||
NumberInput,
|
NumberInput,
|
||||||
@@ -20,7 +18,7 @@ import {
|
|||||||
import { useDisclosure } from '@mantine/hooks';
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { IconEdit, IconTrash, IconPlus, IconInfoCircle } from '@tabler/icons-react';
|
import { IconEdit, IconTrash, IconPlus, IconInfoCircle } from '@tabler/icons-react';
|
||||||
import { notify, useErrorHandler } from '@ema-platform/ui';
|
import { notify, useErrorHandler, AdvancedTable, useServerTable, type AdvancedColumn } from '@ema-platform/ui';
|
||||||
import { useGetCertificationsQuery } from '../../certification/api/certification-api';
|
import { useGetCertificationsQuery } from '../../certification/api/certification-api';
|
||||||
import {
|
import {
|
||||||
useGetQuestionsQuery,
|
useGetQuestionsQuery,
|
||||||
@@ -104,7 +102,8 @@ export function QuestionPage() {
|
|||||||
const locale = i18n.language as 'en' | 'am';
|
const locale = i18n.language as 'en' | 'am';
|
||||||
const { handleError } = useErrorHandler();
|
const { handleError } = useErrorHandler();
|
||||||
const { data: certRes } = useGetCertificationsQuery();
|
const { data: certRes } = useGetCertificationsQuery();
|
||||||
const { data, isLoading, isError } = useGetQuestionsQuery();
|
const { data, isFetching, isError, refetch } = useGetQuestionsQuery();
|
||||||
|
const { setPageIndex, pageSize, paginate } = useServerTable({ pageSize: 25 });
|
||||||
const [createQ, { isLoading: isCreating }] = useCreateQuestionMutation();
|
const [createQ, { isLoading: isCreating }] = useCreateQuestionMutation();
|
||||||
const [updateQ, { isLoading: isUpdating }] = useUpdateQuestionMutation();
|
const [updateQ, { isLoading: isUpdating }] = useUpdateQuestionMutation();
|
||||||
const [deleteQ] = useDeleteQuestionMutation();
|
const [deleteQ] = useDeleteQuestionMutation();
|
||||||
@@ -121,6 +120,7 @@ export function QuestionPage() {
|
|||||||
const certOptions = certifications.filter((c) => c.isActive).map((c) => ({ value: c.id, label: c.name[locale] }));
|
const certOptions = certifications.filter((c) => c.isActive).map((c) => ({ value: c.id, label: c.name[locale] }));
|
||||||
|
|
||||||
const filtered = questions.filter((q) => !certFilter || q.certificationId === certFilter);
|
const filtered = questions.filter((q) => !certFilter || q.certificationId === certFilter);
|
||||||
|
const page = paginate(filtered);
|
||||||
|
|
||||||
const getCertName = (id: string) => certifications.find((c) => c.id === id)?.name?.[locale] ?? '-';
|
const getCertName = (id: string) => certifications.find((c) => c.id === id)?.name?.[locale] ?? '-';
|
||||||
|
|
||||||
@@ -158,9 +158,53 @@ export function QuestionPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading) return <Center py="xl"><Loader /></Center>;
|
|
||||||
if (isError) return <Alert icon={<IconInfoCircle size={16} />} color="red" title={t('question.loadError')} />;
|
if (isError) return <Alert icon={<IconInfoCircle size={16} />} color="red" title={t('question.loadError')} />;
|
||||||
|
|
||||||
|
const columns: AdvancedColumn<Question>[] = [
|
||||||
|
{
|
||||||
|
header: t('question.columns.title'),
|
||||||
|
cell: ({ row }) => <Text fz="sm" maw={300} lineClamp={2}>{row.original.title[locale]}</Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: t('question.columns.certification'),
|
||||||
|
cell: ({ row }) => <Text fz="sm">{getCertName(row.original.certificationId)}</Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: t('question.columns.form'),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Badge size="sm" variant="light" color={row.original.form === 'ESSAY' ? 'blue' : 'violet'}>
|
||||||
|
{t(`question.form.${row.original.form === 'ESSAY' ? 'essay' : 'choice'}`)}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: t('question.columns.points'),
|
||||||
|
cell: ({ row }) => <Text fz="sm" fw={600}>{row.original.points}</Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: t('question.columns.status'),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Badge size="sm" variant="light" color={row.original.isActive ? 'teal' : 'gray'}>
|
||||||
|
{row.original.isActive ? t('question.status.active') : t('question.status.inactive')}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: '',
|
||||||
|
label: t('question.columns.actions', 'Actions'),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Group gap="xs">
|
||||||
|
<ActionIcon variant="subtle" color="blue" size="sm" onClick={() => { setEditing(row.original); setShowForm(true); }}>
|
||||||
|
<IconEdit size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => { setDeleteTarget(row.original); openDelete(); }}>
|
||||||
|
<IconTrash size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<Group justify="space-between" align="flex-end">
|
<Group justify="space-between" align="flex-end">
|
||||||
@@ -182,54 +226,32 @@ export function QuestionPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Paper withBorder radius="md">
|
<Card withBorder padding={0}>
|
||||||
<Group p="md" justify="space-between" wrap="wrap" gap="sm">
|
<Group p="md" justify="space-between" wrap="wrap" gap="sm">
|
||||||
<Text fw={600}>{t('question.pool')}</Text>
|
<Text fw={600}>{t('question.pool')}</Text>
|
||||||
<Select placeholder={t('question.filterByCertification')} data={[{ value: '', label: 'All' }, ...certOptions]} value={certFilter} onChange={(v) => setCertFilter(v ?? null)} size="sm" style={{ width: 280 }} clearable />
|
<Select
|
||||||
|
placeholder={t('question.filterByCertification')}
|
||||||
|
data={[{ value: '', label: 'All' }, ...certOptions]}
|
||||||
|
value={certFilter}
|
||||||
|
onChange={(v) => { setCertFilter(v ?? null); setPageIndex(0); }}
|
||||||
|
size="sm"
|
||||||
|
style={{ width: 280 }}
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
<Table striped highlightOnHover>
|
<AdvancedTable
|
||||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
columns={columns}
|
||||||
<Table.Tr>
|
data={page.rows}
|
||||||
<Table.Th>{t('question.columns.title')}</Table.Th>
|
tableName={t('question.title')}
|
||||||
<Table.Th>{t('question.columns.certification')}</Table.Th>
|
itemCount={page.itemCount}
|
||||||
<Table.Th>{t('question.columns.form')}</Table.Th>
|
pageIndex={page.pageIndex}
|
||||||
<Table.Th>{t('question.columns.points')}</Table.Th>
|
onPageChange={setPageIndex}
|
||||||
<Table.Th>{t('question.columns.status')}</Table.Th>
|
pageSize={pageSize}
|
||||||
<Table.Th />
|
refresh={refetch}
|
||||||
</Table.Tr>
|
isLoading={isFetching}
|
||||||
</Table.Thead>
|
emptyText={t('question.noQuestions')}
|
||||||
<Table.Tbody>
|
/>
|
||||||
{filtered.map((q) => (
|
</Card>
|
||||||
<Table.Tr key={q.id}>
|
|
||||||
<Table.Td><Text fz="sm" maw={300} lineClamp={2}>{q.title[locale]}</Text></Table.Td>
|
|
||||||
<Table.Td><Text fz="sm">{getCertName(q.certificationId)}</Text></Table.Td>
|
|
||||||
<Table.Td><Badge size="sm" variant="light" color={q.form === 'ESSAY' ? 'blue' : 'violet'}>{t(`question.form.${q.form === 'ESSAY' ? 'essay' : 'choice'}`)}</Badge></Table.Td>
|
|
||||||
<Table.Td><Text fz="sm" fw={600}>{q.points}</Text></Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Badge size="sm" variant="light" color={q.isActive ? 'teal' : 'gray'}>{q.isActive ? t('question.status.active') : t('question.status.inactive')}</Badge>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Group gap="xs">
|
|
||||||
<ActionIcon variant="subtle" color="blue" size="sm" onClick={() => { setEditing(q); setShowForm(true); }}>
|
|
||||||
<IconEdit size={14} />
|
|
||||||
</ActionIcon>
|
|
||||||
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => { setDeleteTarget(q); openDelete(); }}>
|
|
||||||
<IconTrash size={14} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Group>
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
))}
|
|
||||||
{filtered.length === 0 && (
|
|
||||||
<Table.Tr>
|
|
||||||
<Table.Td colSpan={6}>
|
|
||||||
<Text c="dimmed" ta="center" py="xl">{t('question.noQuestions')}</Text>
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
)}
|
|
||||||
</Table.Tbody>
|
|
||||||
</Table>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
<Modal opened={deleteOpened} onClose={closeDelete} title={t('question.confirmDelete')} size="sm">
|
<Modal opened={deleteOpened} onClose={closeDelete} title={t('question.confirmDelete')} size="sm">
|
||||||
<Text mb="md">{t('question.deleteConfirmText')}</Text>
|
<Text mb="md">{t('question.deleteConfirmText')}</Text>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Modal,
|
Modal,
|
||||||
Text,
|
Text,
|
||||||
Paper,
|
Paper,
|
||||||
|
Card,
|
||||||
Loader,
|
Loader,
|
||||||
Center,
|
Center,
|
||||||
Alert,
|
Alert,
|
||||||
@@ -35,7 +36,7 @@ import {
|
|||||||
IconChartBar,
|
IconChartBar,
|
||||||
IconSearch,
|
IconSearch,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { notify, BilingualInput, useErrorHandler } from '@ema-platform/ui';
|
import { notify, BilingualInput, useErrorHandler, AdvancedTable, useServerTable, type AdvancedColumn } from '@ema-platform/ui';
|
||||||
import type { BilingualValue } from '@ema-platform/ui';
|
import type { BilingualValue } from '@ema-platform/ui';
|
||||||
import { useGetResultsQuery, useLazyGetResultQuery, useDeleteResultMutation, useUpdateResultMutation } from '../api/result-api';
|
import { useGetResultsQuery, useLazyGetResultQuery, useDeleteResultMutation, useUpdateResultMutation } from '../api/result-api';
|
||||||
import { useGetExamsQuery } from '../../exam/api/exam-api';
|
import { useGetExamsQuery } from '../../exam/api/exam-api';
|
||||||
@@ -97,7 +98,8 @@ export function ResultPage() {
|
|||||||
const locale = i18n.language as 'en' | 'am';
|
const locale = i18n.language as 'en' | 'am';
|
||||||
const { handleError } = useErrorHandler();
|
const { handleError } = useErrorHandler();
|
||||||
const { data: examRes } = useGetExamsQuery();
|
const { data: examRes } = useGetExamsQuery();
|
||||||
const { data, isLoading, isError } = useGetResultsQuery();
|
const { data, isFetching, isError, refetch } = useGetResultsQuery();
|
||||||
|
const { setPageIndex, pageSize, paginate } = useServerTable({ pageSize: 25 });
|
||||||
const [fetchDetail, { data: detailResult, isFetching: isDetailLoading }] = useLazyGetResultQuery();
|
const [fetchDetail, { data: detailResult, isFetching: isDetailLoading }] = useLazyGetResultQuery();
|
||||||
|
|
||||||
const exams = examRes?.items ?? [];
|
const exams = examRes?.items ?? [];
|
||||||
@@ -207,9 +209,76 @@ export function ResultPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading) return <Center py="xl"><Loader /></Center>;
|
|
||||||
if (isError) return <Alert icon={<IconInfoCircle size={16} />} color="red" title={t('result.loadError')} />;
|
if (isError) return <Alert icon={<IconInfoCircle size={16} />} color="red" title={t('result.loadError')} />;
|
||||||
|
|
||||||
|
const columns: AdvancedColumn<Result>[] = [
|
||||||
|
{
|
||||||
|
header: t('result.columns.seafarer'),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Text fz="sm" fw={500}>
|
||||||
|
{row.original.seafarer
|
||||||
|
? `${row.original.seafarer.firstName} ${row.original.seafarer.lastName}`
|
||||||
|
: row.original.seafarerId.slice(0, 8)}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: t('result.columns.exam'),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Text fz="sm">{row.original.exam ? row.original.exam.title[locale] : getExamTitle(row.original.examId)}</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: t('result.columns.totalScore'),
|
||||||
|
cell: ({ row }) => <Text fz="sm" fw={600}>{row.original.totalScore}</Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: t('result.columns.status'),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Badge
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
color={STATUS_COLOR[row.original.status]}
|
||||||
|
leftSection={
|
||||||
|
<Box
|
||||||
|
w={6}
|
||||||
|
h={6}
|
||||||
|
style={{ borderRadius: 999, background: `var(--mantine-color-${STATUS_COLOR[row.original.status]}-6)` }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t(`result.status.${row.original.status}`)}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: t('result.columns.date'),
|
||||||
|
cell: ({ row }) => <Text fz="sm">{new Date(row.original.createdAt).toLocaleDateString()}</Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: '',
|
||||||
|
label: t('result.columns.actions', 'Actions'),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Group gap="xs">
|
||||||
|
<Button size="xs" variant="subtle" leftSection={<IconEye size={13} />} onClick={() => viewDetail(row.original)}>
|
||||||
|
{t('result.action.viewEdit')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="subtle"
|
||||||
|
color="red"
|
||||||
|
leftSection={<IconTrash size={13} />}
|
||||||
|
onClick={() => { setDeleteTarget(row.original); openDelete(); }}
|
||||||
|
>
|
||||||
|
{t('result.action.delete')}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const page = paginate(filtered);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<Group justify="space-between" align="flex-end">
|
<Group justify="space-between" align="flex-end">
|
||||||
@@ -229,7 +298,7 @@ export function ResultPage() {
|
|||||||
<ResultStat label={t('result.stats.avgScore')} value={avgScore} icon={IconChartBar} color="indigo" />
|
<ResultStat label={t('result.stats.avgScore')} value={avgScore} icon={IconChartBar} color="indigo" />
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|
||||||
<Paper withBorder radius="md">
|
<Card withBorder padding={0}>
|
||||||
<Group p="md" justify="space-between" wrap="wrap" gap="sm">
|
<Group p="md" justify="space-between" wrap="wrap" gap="sm">
|
||||||
<Text fw={600}>{t('result.section')}</Text>
|
<Text fw={600}>{t('result.section')}</Text>
|
||||||
<Group gap="sm" wrap="wrap">
|
<Group gap="sm" wrap="wrap">
|
||||||
@@ -237,7 +306,7 @@ export function ResultPage() {
|
|||||||
placeholder={t('result.search.seafarer')}
|
placeholder={t('result.search.seafarer')}
|
||||||
leftSection={<IconSearch size={15} />}
|
leftSection={<IconSearch size={15} />}
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.currentTarget.value)}
|
onChange={(e) => { setSearchQuery(e.currentTarget.value); setPageIndex(0); }}
|
||||||
size="sm"
|
size="sm"
|
||||||
style={{ width: 240 }}
|
style={{ width: 240 }}
|
||||||
/>
|
/>
|
||||||
@@ -245,7 +314,7 @@ export function ResultPage() {
|
|||||||
placeholder={t('result.search.filterByExam')}
|
placeholder={t('result.search.filterByExam')}
|
||||||
data={[{ value: '', label: t('result.search.allExams') }, ...examOptions]}
|
data={[{ value: '', label: t('result.search.allExams') }, ...examOptions]}
|
||||||
value={examFilter}
|
value={examFilter}
|
||||||
onChange={(v) => setExamFilter(v ?? null)}
|
onChange={(v) => { setExamFilter(v ?? null); setPageIndex(0); }}
|
||||||
size="sm"
|
size="sm"
|
||||||
style={{ width: 280 }}
|
style={{ width: 280 }}
|
||||||
clearable
|
clearable
|
||||||
@@ -253,77 +322,19 @@ export function ResultPage() {
|
|||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Table striped highlightOnHover>
|
<AdvancedTable
|
||||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
columns={columns}
|
||||||
<Table.Tr>
|
data={page.rows}
|
||||||
<Table.Th>{t('result.columns.seafarer')}</Table.Th>
|
tableName={t('result.title')}
|
||||||
<Table.Th>{t('result.columns.exam')}</Table.Th>
|
itemCount={page.itemCount}
|
||||||
<Table.Th>{t('result.columns.totalScore')}</Table.Th>
|
pageIndex={page.pageIndex}
|
||||||
<Table.Th>{t('result.columns.status')}</Table.Th>
|
onPageChange={setPageIndex}
|
||||||
<Table.Th>{t('result.columns.date')}</Table.Th>
|
pageSize={pageSize}
|
||||||
<Table.Th />
|
refresh={refetch}
|
||||||
</Table.Tr>
|
isLoading={isFetching}
|
||||||
</Table.Thead>
|
emptyText={t('result.noItems')}
|
||||||
<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)` }}
|
|
||||||
/>
|
/>
|
||||||
}
|
</Card>
|
||||||
>
|
|
||||||
{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
|
<Modal
|
||||||
opened={detailOpened}
|
opened={detailOpened}
|
||||||
|
|||||||
@@ -2,17 +2,15 @@ import { useState } from 'react';
|
|||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
Card,
|
Card,
|
||||||
Center,
|
|
||||||
Container,
|
Container,
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
|
||||||
Table,
|
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
Title,
|
Title,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { IconSearch } from '@tabler/icons-react';
|
import { IconSearch } from '@tabler/icons-react';
|
||||||
import { useApiQuery } from '@ema-platform/api';
|
import { useApiQuery } from '@ema-platform/api';
|
||||||
|
import { AdvancedTable, useServerTable, type AdvancedColumn } from '@ema-platform/ui';
|
||||||
|
|
||||||
interface ProfileRow {
|
interface ProfileRow {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -34,11 +32,12 @@ interface ProfileRow {
|
|||||||
*/
|
*/
|
||||||
export function SeafarerRegistryPage() {
|
export function SeafarerRegistryPage() {
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const { data, isLoading } = useApiQuery<{ total: number; items: ProfileRow[] }>({
|
const { data, isFetching, refetch } = useApiQuery<{ total: number; items: ProfileRow[] }>({
|
||||||
url: '/profiles',
|
url: '/profiles',
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
params: { q: 'i=profession,address&t=200' },
|
params: { q: 'i=profession,address&t=200' },
|
||||||
});
|
});
|
||||||
|
const { setPageIndex, pageSize, paginate } = useServerTable({ pageSize: 25 });
|
||||||
|
|
||||||
const items = (data?.items ?? []).filter((p) => {
|
const items = (data?.items ?? []).filter((p) => {
|
||||||
if (!search.trim()) return true;
|
if (!search.trim()) return true;
|
||||||
@@ -47,6 +46,38 @@ export function SeafarerRegistryPage() {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.some((v) => String(v).toLowerCase().includes(term));
|
.some((v) => String(v).toLowerCase().includes(term));
|
||||||
});
|
});
|
||||||
|
const page = paginate(items);
|
||||||
|
|
||||||
|
const columns: AdvancedColumn<ProfileRow>[] = [
|
||||||
|
{
|
||||||
|
header: 'Name',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{[row.original.firstName, row.original.middleName, row.original.lastName].filter(Boolean).join(' ')}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Profession',
|
||||||
|
cell: ({ row }) => <Text size="sm">{row.original.profession?.name?.en ?? '—'}</Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'ID number',
|
||||||
|
cell: ({ row }) => <Text size="sm" c="dimmed">{row.original.address?.idNumber ?? '—'}</Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Phone',
|
||||||
|
cell: ({ row }) => <Text size="sm" c="dimmed">{row.original.address?.primaryPhoneNumber ?? '—'}</Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Status',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Badge size="sm" variant="light" color={row.original.isComplete ? 'teal' : 'gray'}>
|
||||||
|
{row.original.isComplete ? 'Complete' : 'Incomplete'}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size="xl" py="md">
|
<Container size="xl" py="md">
|
||||||
@@ -61,64 +92,24 @@ export function SeafarerRegistryPage() {
|
|||||||
placeholder="Name or ID number"
|
placeholder="Name or ID number"
|
||||||
leftSection={<IconSearch size={14} />}
|
leftSection={<IconSearch size={14} />}
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
onChange={(e) => { setSearch(e.currentTarget.value); setPageIndex(0); }}
|
||||||
w={260}
|
w={260}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Card withBorder padding={0}>
|
<Card withBorder padding={0}>
|
||||||
{isLoading ? (
|
<AdvancedTable
|
||||||
<Center h={200}>
|
columns={columns}
|
||||||
<Loader />
|
data={page.rows}
|
||||||
</Center>
|
tableName="Seafarer registry"
|
||||||
) : items.length === 0 ? (
|
itemCount={page.itemCount}
|
||||||
<Center h={160}>
|
pageIndex={page.pageIndex}
|
||||||
<Text size="sm" c="dimmed">
|
onPageChange={setPageIndex}
|
||||||
{search ? 'No profiles match that search.' : 'No seafarers registered yet.'}
|
pageSize={pageSize}
|
||||||
</Text>
|
refresh={refetch}
|
||||||
</Center>
|
isLoading={isFetching}
|
||||||
) : (
|
emptyText={search ? 'No profiles match that search.' : 'No seafarers registered yet.'}
|
||||||
<Table highlightOnHover>
|
/>
|
||||||
<Table.Thead>
|
|
||||||
<Table.Tr>
|
|
||||||
<Table.Th>Name</Table.Th>
|
|
||||||
<Table.Th>Profession</Table.Th>
|
|
||||||
<Table.Th>ID number</Table.Th>
|
|
||||||
<Table.Th>Phone</Table.Th>
|
|
||||||
<Table.Th>Status</Table.Th>
|
|
||||||
</Table.Tr>
|
|
||||||
</Table.Thead>
|
|
||||||
<Table.Tbody>
|
|
||||||
{items.map((p) => (
|
|
||||||
<Table.Tr key={p.id}>
|
|
||||||
<Table.Td>
|
|
||||||
<Text size="sm" fw={500}>
|
|
||||||
{[p.firstName, p.middleName, p.lastName].filter(Boolean).join(' ')}
|
|
||||||
</Text>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Text size="sm">{p.profession?.name?.en ?? '—'}</Text>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{p.address?.idNumber ?? '—'}
|
|
||||||
</Text>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{p.address?.primaryPhoneNumber ?? '—'}
|
|
||||||
</Text>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Badge size="sm" variant="light" color={p.isComplete ? 'teal' : 'gray'}>
|
|
||||||
{p.isComplete ? 'Complete' : 'Incomplete'}
|
|
||||||
</Badge>
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
))}
|
|
||||||
</Table.Tbody>
|
|
||||||
</Table>
|
|
||||||
)}
|
|
||||||
</Card>
|
</Card>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -98,6 +98,10 @@ export const am: Translations = {
|
|||||||
collapse: 'ሰብስብ',
|
collapse: 'ሰብስብ',
|
||||||
expand: 'ዘርጋ',
|
expand: 'ዘርጋ',
|
||||||
toggleTheme: 'የብርሃን / ጨለማ ሁነታን ይቀይሩ',
|
toggleTheme: 'የብርሃን / ጨለማ ሁነታን ይቀይሩ',
|
||||||
|
refresh: 'አድስ',
|
||||||
|
view: 'ይመልከቱ',
|
||||||
|
toggleColumns: 'አምዶችን ቀያይር',
|
||||||
|
noResult: 'ምንም አልተገኘም',
|
||||||
},
|
},
|
||||||
|
|
||||||
breadcrumbs: {
|
breadcrumbs: {
|
||||||
|
|||||||
@@ -96,6 +96,10 @@ export const en = {
|
|||||||
collapse: 'Collapse',
|
collapse: 'Collapse',
|
||||||
expand: 'Expand',
|
expand: 'Expand',
|
||||||
toggleTheme: 'Toggle light / dark mode',
|
toggleTheme: 'Toggle light / dark mode',
|
||||||
|
refresh: 'Refresh',
|
||||||
|
view: 'View',
|
||||||
|
toggleColumns: 'Toggle columns',
|
||||||
|
noResult: 'No results',
|
||||||
},
|
},
|
||||||
|
|
||||||
breadcrumbs: {
|
breadcrumbs: {
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
import { REQUEST_TIMEOUT_MS } from '../constants';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The ONLY file in this feature that knows a URL, a header name, an env var, or `fetch` exists.
|
|
||||||
* Everything above this file (services, hooks, components) calls `paymentFetch` and never sees
|
|
||||||
* any of that — so swapping direct-to-payment-service calls for a backend proxy later means
|
|
||||||
* changing this file alone: repoint VITE_PAYMENT_API_URL at the proxy path and nothing else moves.
|
|
||||||
*/
|
|
||||||
|
|
||||||
function readEnv(name: string): string | undefined {
|
|
||||||
return (import.meta as { env?: Record<string, string> }).env?.[name];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PaymentClientConfig {
|
|
||||||
baseUrl: string;
|
|
||||||
serviceToken: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveConfig(): PaymentClientConfig {
|
|
||||||
const baseUrl = readEnv('VITE_PAYMENT_API_URL');
|
|
||||||
const serviceToken = readEnv('VITE_PAYMENT_SERVICE_TOKEN');
|
|
||||||
if (!baseUrl || !serviceToken) {
|
|
||||||
// Thrown lazily (only when a payment is actually attempted), not at module load — a
|
|
||||||
// module-load throw would break the whole bundle for every user who never pays.
|
|
||||||
throw new Error('Payment is not configured. Contact support.');
|
|
||||||
}
|
|
||||||
return { baseUrl, serviceToken };
|
|
||||||
}
|
|
||||||
|
|
||||||
function messageForStatus(status: number): string {
|
|
||||||
if (status >= 500) return 'The payment service is unavailable right now. Please try again.';
|
|
||||||
if (status === 404) return 'The payment could not be found.';
|
|
||||||
if (status >= 400) return 'The payment service rejected the request.';
|
|
||||||
return `The payment service responded with an unexpected status (${status}).`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Reads a possibly-empty response body as text, never letting a malformed body throw raw. */
|
|
||||||
async function parseBody(res: Response): Promise<unknown> {
|
|
||||||
const text = await res.text();
|
|
||||||
if (!text) return undefined; // empty body (e.g. 204) is valid, not an error
|
|
||||||
try {
|
|
||||||
return JSON.parse(text);
|
|
||||||
} catch {
|
|
||||||
return undefined; // malformed JSON is handled by the caller, never surfaced as a raw SyntaxError
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractServerMessage(body: unknown): string | undefined {
|
|
||||||
if (typeof body !== 'object' || body === null) return undefined;
|
|
||||||
const record = body as Record<string, unknown>;
|
|
||||||
const message = record['message'] ?? record['error'] ?? record['detail'];
|
|
||||||
return typeof message === 'string' ? message : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The single request path for the whole feature. Attaches Content-Type + x-service-token exactly
|
|
||||||
* once, times every call out, and never lets a raw fetch/parse failure escape — every error path
|
|
||||||
* throws a plain Error with a message that's safe to show the user directly (ApiErrorAlert renders
|
|
||||||
* Error.message as-is).
|
|
||||||
*/
|
|
||||||
export async function paymentFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
|
||||||
const { baseUrl, serviceToken } = resolveConfig();
|
|
||||||
|
|
||||||
let res: Response;
|
|
||||||
try {
|
|
||||||
res = await fetch(`${baseUrl}${path}`, {
|
|
||||||
...init,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'x-service-token': serviceToken,
|
|
||||||
...init?.headers,
|
|
||||||
},
|
|
||||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
|
||||||
throw new Error('The payment service took too long to respond.');
|
|
||||||
}
|
|
||||||
// fetch rejects with TypeError on network failure (offline, DNS, CORS) — never re-throw it raw.
|
|
||||||
throw new Error("Can't reach the payment service. Check your connection and try again.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await parseBody(res);
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(extractServerMessage(body) ?? messageForStatus(res.status));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (body === undefined) {
|
|
||||||
// A 2xx with an unreadable/empty body is only valid for endpoints that don't promise a payload;
|
|
||||||
// callers that need a value will simply get `undefined` typed as T rather than a thrown SyntaxError.
|
|
||||||
return undefined as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
return body as T;
|
|
||||||
}
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
import { describe, expect, it, beforeEach } from 'vitest';
|
|
||||||
import {
|
|
||||||
isSuccessStatus,
|
|
||||||
isTerminalStatus,
|
|
||||||
listPaymentHistory,
|
|
||||||
recordPayment,
|
|
||||||
updatePaymentStatus,
|
|
||||||
} from './payments';
|
|
||||||
import { toMinor } from '../utils/money';
|
|
||||||
import type { PaymentHistoryRecord } from '../types/payment';
|
|
||||||
|
|
||||||
// jsdom/happy-dom aren't installed in this repo, so localStorage is stubbed with a minimal
|
|
||||||
// in-memory shim rather than adding a dependency for one test file.
|
|
||||||
function installLocalStorageStub() {
|
|
||||||
const store = new Map<string, string>();
|
|
||||||
(globalThis as { localStorage?: Storage }).localStorage = {
|
|
||||||
getItem: (key: string) => store.get(key) ?? null,
|
|
||||||
setItem: (key: string, value: string) => void store.set(key, value),
|
|
||||||
removeItem: (key: string) => void store.delete(key),
|
|
||||||
clear: () => store.clear(),
|
|
||||||
key: () => null,
|
|
||||||
get length() {
|
|
||||||
return store.size;
|
|
||||||
},
|
|
||||||
} as Storage;
|
|
||||||
}
|
|
||||||
installLocalStorageStub();
|
|
||||||
|
|
||||||
const sampleRecord: PaymentHistoryRecord = {
|
|
||||||
intentId: 'intent-1',
|
|
||||||
title: 'Seaman Book Application',
|
|
||||||
orderRef: 'SB-ABC123',
|
|
||||||
referenceId: 'SB-ABC123',
|
|
||||||
provider: 'TELEBIRR',
|
|
||||||
amountMinor: 140000,
|
|
||||||
currency: 'ETB',
|
|
||||||
status: 'PENDING',
|
|
||||||
createdAt: '2026-01-01T00:00:00.000Z',
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
localStorage.clear();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('isTerminalStatus / isSuccessStatus', () => {
|
|
||||||
it('treats an unknown status as neither terminal nor success', () => {
|
|
||||||
expect(isTerminalStatus('WEIRD_STATE')).toBe(false);
|
|
||||||
expect(isSuccessStatus('WEIRD_STATE')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('treats PAY_SUCCESS (Telebirr return param) as both terminal and success', () => {
|
|
||||||
expect(isTerminalStatus('PAY_SUCCESS')).toBe(true);
|
|
||||||
expect(isSuccessStatus('PAY_SUCCESS')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is case-insensitive', () => {
|
|
||||||
expect(isTerminalStatus('failed')).toBe(true);
|
|
||||||
expect(isSuccessStatus('success')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('treats undefined as neither', () => {
|
|
||||||
expect(isTerminalStatus(undefined)).toBe(false);
|
|
||||||
expect(isSuccessStatus(undefined)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('toMinor', () => {
|
|
||||||
it('rounds to the nearest minor unit', () => {
|
|
||||||
expect(toMinor(19.99)).toBe(1999);
|
|
||||||
expect(toMinor(1400)).toBe(140000);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('payment history round-trip', () => {
|
|
||||||
it('records, lists, and updates a payment', () => {
|
|
||||||
recordPayment(sampleRecord);
|
|
||||||
expect(listPaymentHistory()).toHaveLength(1);
|
|
||||||
|
|
||||||
updatePaymentStatus('intent-1', 'SUCCESS');
|
|
||||||
const [updated] = listPaymentHistory();
|
|
||||||
expect(updated.status).toBe('SUCCESS');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('upserts by intentId instead of duplicating', () => {
|
|
||||||
recordPayment(sampleRecord);
|
|
||||||
recordPayment({ ...sampleRecord, status: 'FAILED' });
|
|
||||||
const history = listPaymentHistory();
|
|
||||||
expect(history).toHaveLength(1);
|
|
||||||
expect(history[0].status).toBe('FAILED');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not rewrite storage when the status is unchanged', () => {
|
|
||||||
recordPayment(sampleRecord);
|
|
||||||
const before = localStorage.getItem('ema-payment-history');
|
|
||||||
updatePaymentStatus('intent-1', 'PENDING'); // same status as recorded
|
|
||||||
expect(localStorage.getItem('ema-payment-history')).toBe(before);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('tolerates corrupt JSON and returns an empty list', () => {
|
|
||||||
localStorage.setItem('ema-payment-history', '{not json');
|
|
||||||
expect(listPaymentHistory()).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
import { paymentFetch } from './client';
|
|
||||||
import { PAYMENT_HISTORY_KEY, SUCCESS_STATUSES, TERMINAL_STATUSES } from '../constants';
|
|
||||||
import type {
|
|
||||||
PaymentHistoryRecord,
|
|
||||||
PaymentIntent,
|
|
||||||
PaymentRequest,
|
|
||||||
PaymentStatus,
|
|
||||||
} from '../types/payment';
|
|
||||||
|
|
||||||
// The complete surface components/hooks are allowed to import. Everything below goes through
|
|
||||||
// `paymentFetch` — nothing here (or above it) knows a URL, token, header, or `fetch` exists.
|
|
||||||
|
|
||||||
/** POST /payments/initiate — the only way a new payment intent gets created. */
|
|
||||||
export function initiatePayment(req: PaymentRequest): Promise<PaymentIntent> {
|
|
||||||
return paymentFetch<PaymentIntent>('/payments/initiate', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(req),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** GET /payments/intents/:intentId — used both by the poll and by manual "check again". */
|
|
||||||
export function getIntent(intentId: string): Promise<PaymentIntent> {
|
|
||||||
return paymentFetch<PaymentIntent>(`/payments/intents/${intentId}`);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /payments/intents/:intentId/confirm — part of the documented contract, exported for
|
|
||||||
* completeness. No requirement asks for an OTP screen and the modal's responsibilities don't
|
|
||||||
* include one, so nothing in the UI calls this yet; building an OTP flow would be inventing
|
|
||||||
* behaviour the spec never asked for.
|
|
||||||
*/
|
|
||||||
export function confirmIntent(intentId: string, otp: string): Promise<PaymentIntent> {
|
|
||||||
return paymentFetch<PaymentIntent>(`/payments/intents/${intentId}/confirm`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ otp }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Both predicates default to `false` for an unrecognised or missing status. That default matters
|
|
||||||
* in both directions: an unknown status must never be read as success, and it must never stop the
|
|
||||||
* poll (refetchInterval keys off isTerminalStatus — `false` means "keep polling"). Guessing the
|
|
||||||
* other way in either case would silently lose or fake a payment.
|
|
||||||
*/
|
|
||||||
export function isTerminalStatus(status: PaymentStatus | undefined): boolean {
|
|
||||||
return !!status && TERMINAL_STATUSES.has(status.toUpperCase());
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isSuccessStatus(status: PaymentStatus | undefined): boolean {
|
|
||||||
return !!status && SUCCESS_STATUSES.has(status.toUpperCase());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- localStorage history -------------------------------------------------
|
|
||||||
|
|
||||||
export function listPaymentHistory(): PaymentHistoryRecord[] {
|
|
||||||
const raw = localStorage.getItem(PAYMENT_HISTORY_KEY);
|
|
||||||
if (!raw) return [];
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
return Array.isArray(parsed) ? (parsed as PaymentHistoryRecord[]) : [];
|
|
||||||
} catch {
|
|
||||||
return []; // corrupt JSON is treated as "no history", never a crash
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function savePaymentHistory(records: PaymentHistoryRecord[]): void {
|
|
||||||
localStorage.setItem(PAYMENT_HISTORY_KEY, JSON.stringify(records));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Upsert by intentId — a retry creates a new record; re-recording the same intent replaces it in place. */
|
|
||||||
export function recordPayment(record: PaymentHistoryRecord): void {
|
|
||||||
const history = listPaymentHistory();
|
|
||||||
const idx = history.findIndex((r) => r.intentId === record.intentId);
|
|
||||||
if (idx === -1) {
|
|
||||||
history.push(record);
|
|
||||||
} else {
|
|
||||||
history[idx] = record;
|
|
||||||
}
|
|
||||||
savePaymentHistory(history);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updatePaymentStatus(intentId: string, status: PaymentStatus): void {
|
|
||||||
const history = listPaymentHistory();
|
|
||||||
const record = history.find((r) => r.intentId === intentId);
|
|
||||||
if (!record || record.status === status) return; // no-op write: reconcile must not churn storage
|
|
||||||
record.status = status;
|
|
||||||
savePaymentHistory(history);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Refreshes AT MOST ONE record from the backend and writes it back if its status changed.
|
|
||||||
*
|
|
||||||
* Why one record, not all: the Telebirr return URL identifies exactly one payment (`merch_order_id`),
|
|
||||||
* the documented contract has no batch-status endpoint, and a terminal record can never change again —
|
|
||||||
* so re-fetching the rest of history would be N requests buying zero new information. When `orderRef`
|
|
||||||
* is omitted (a bare `reconcilePaymentHistory()` call), the single most-recent non-terminal record is
|
|
||||||
* used instead, since that's the only one that could plausibly still be in flight.
|
|
||||||
*
|
|
||||||
* A failed fetch leaves the local record untouched (best-effort refresh, never blanks history) and is
|
|
||||||
* swallowed to a console.warn — reconciliation must not crash the page it's called from.
|
|
||||||
*/
|
|
||||||
export async function reconcilePaymentHistory(orderRef?: string): Promise<PaymentHistoryRecord[]> {
|
|
||||||
const history = listPaymentHistory();
|
|
||||||
|
|
||||||
const target = orderRef
|
|
||||||
? history.find((r) => r.orderRef === orderRef)
|
|
||||||
: [...history].reverse().find((r) => !isTerminalStatus(r.status));
|
|
||||||
|
|
||||||
if (!target || isTerminalStatus(target.status)) {
|
|
||||||
return history; // nothing to refresh — zero requests
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const intent = await getIntent(target.intentId);
|
|
||||||
updatePaymentStatus(target.intentId, intent.status);
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('reconcilePaymentHistory: failed to refresh intent', target.intentId, err);
|
|
||||||
}
|
|
||||||
|
|
||||||
return listPaymentHistory();
|
|
||||||
}
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
import { Alert, Anchor, Badge, Button, Group, Loader, Modal, Stack, Text } from '@mantine/core';
|
|
||||||
import { IconAlertTriangle, IconCheck } from '@tabler/icons-react';
|
|
||||||
import { ApiErrorAlert } from '@ema-platform/ui';
|
|
||||||
import { usePaymentFlow } from '../hooks/usePaymentFlow';
|
|
||||||
import { formatMinor } from '../utils/money';
|
|
||||||
import { DEFAULT_PAYMENT_PROVIDER, PAYMENT_PHASE } from '../constants';
|
|
||||||
import type { PaymentIntent, PaymentProvider } from '../types/payment';
|
|
||||||
|
|
||||||
export interface PaymentModalProps {
|
|
||||||
opened: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
title: string;
|
|
||||||
amountMinor: number;
|
|
||||||
currency: string;
|
|
||||||
referenceId: string;
|
|
||||||
orderRef: string;
|
|
||||||
service: string;
|
|
||||||
referenceType: string;
|
|
||||||
defaultProvider?: PaymentProvider;
|
|
||||||
lockedProvider?: PaymentProvider;
|
|
||||||
payerAccount?: string;
|
|
||||||
onSuccess?: (intent: PaymentIntent) => void;
|
|
||||||
onFailure?: (intent: PaymentIntent) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reusable Telebirr payment modal. Renders from a single `phase` — see usePaymentFlow — so states
|
|
||||||
* like "loading && success" or "loading && error" are unrepresentable rather than merely avoided.
|
|
||||||
* Talks only to api/payments (via the hook); never touches a URL, token, header, or fetch directly.
|
|
||||||
*/
|
|
||||||
export function PaymentModal({
|
|
||||||
opened,
|
|
||||||
onClose,
|
|
||||||
title,
|
|
||||||
amountMinor,
|
|
||||||
currency,
|
|
||||||
referenceId,
|
|
||||||
orderRef,
|
|
||||||
service,
|
|
||||||
referenceType,
|
|
||||||
defaultProvider,
|
|
||||||
lockedProvider,
|
|
||||||
payerAccount,
|
|
||||||
onSuccess,
|
|
||||||
onFailure,
|
|
||||||
}: PaymentModalProps) {
|
|
||||||
const provider = lockedProvider ?? defaultProvider ?? DEFAULT_PAYMENT_PROVIDER;
|
|
||||||
|
|
||||||
const { phase, intent, error, isSlow, redirectUrl, pay, retry, openPayment, refresh } = usePaymentFlow({
|
|
||||||
opened,
|
|
||||||
title,
|
|
||||||
amountMinor,
|
|
||||||
currency,
|
|
||||||
referenceId,
|
|
||||||
orderRef,
|
|
||||||
service,
|
|
||||||
referenceType,
|
|
||||||
provider,
|
|
||||||
payerAccount,
|
|
||||||
onSuccess,
|
|
||||||
onFailure,
|
|
||||||
});
|
|
||||||
|
|
||||||
const unhandledActionType =
|
|
||||||
intent?.clientAction && intent.clientAction.type !== 'REDIRECT' ? intent.clientAction.type : null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal opened={opened} onClose={onClose} title={title} size="sm" centered closeOnClickOutside={phase !== PAYMENT_PHASE.Creating}>
|
|
||||||
<Stack gap="md">
|
|
||||||
<Group justify="space-between">
|
|
||||||
<Text fz="sm" c="dimmed">Amount</Text>
|
|
||||||
<Text fw={700}>{formatMinor(amountMinor, currency)}</Text>
|
|
||||||
</Group>
|
|
||||||
<Group justify="space-between">
|
|
||||||
<Text fz="sm" c="dimmed">Provider</Text>
|
|
||||||
<Badge color="green" variant="light">{provider}</Badge>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
{phase === PAYMENT_PHASE.Idle && (
|
|
||||||
<Button onClick={pay} fullWidth>
|
|
||||||
Pay with {provider === 'TELEBIRR' ? 'Telebirr' : provider}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{phase === PAYMENT_PHASE.Creating && (
|
|
||||||
<Group gap="xs">
|
|
||||||
<Loader size="sm" />
|
|
||||||
<Text fz="sm" c="dimmed">Creating your payment…</Text>
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{phase === PAYMENT_PHASE.Opening && (
|
|
||||||
<Group gap="xs">
|
|
||||||
<Loader size="sm" />
|
|
||||||
<Text fz="sm" c="dimmed">Opening Telebirr…</Text>
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{phase === PAYMENT_PHASE.Blocked && redirectUrl && (
|
|
||||||
<Alert color="yellow" icon={<IconAlertTriangle size={15} />} title="Popup blocked">
|
|
||||||
<Stack gap="xs">
|
|
||||||
<Text fz="sm">Your browser blocked the payment window. Open it manually to continue.</Text>
|
|
||||||
<Button onClick={openPayment} size="sm">Open Payment</Button>
|
|
||||||
</Stack>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{phase === PAYMENT_PHASE.Waiting && (
|
|
||||||
<Stack gap="xs">
|
|
||||||
<Group gap="xs">
|
|
||||||
<Loader size="sm" />
|
|
||||||
<Text fz="sm" c="dimmed">
|
|
||||||
Waiting for confirmation from Telebirr… {intent ? `(${intent.status})` : null}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
{unhandledActionType && (
|
|
||||||
<Text fz="xs" c="dimmed">
|
|
||||||
The payment service returned an unhandled action ("{unhandledActionType}") — still
|
|
||||||
checking for a result.
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
{redirectUrl && (
|
|
||||||
<Anchor fz="xs" onClick={() => window.open(redirectUrl, '_blank', 'noopener')}>
|
|
||||||
Reopen Telebirr
|
|
||||||
</Anchor>
|
|
||||||
)}
|
|
||||||
{isSlow && (
|
|
||||||
<Alert color="yellow" variant="light" fz="xs">
|
|
||||||
<Stack gap="xs">
|
|
||||||
<Text fz="xs">This is taking longer than usual. It may still complete.</Text>
|
|
||||||
<Group gap="xs">
|
|
||||||
<Button size="xs" variant="default" onClick={refresh}>Check again</Button>
|
|
||||||
<Button size="xs" variant="light" onClick={retry}>Try again</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{phase === PAYMENT_PHASE.Success && (
|
|
||||||
<Alert color="teal" icon={<IconCheck size={15} />} title="Payment confirmed">
|
|
||||||
<Text fz="sm">{intent ? `Reference: ${intent.id}` : null}</Text>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{phase === PAYMENT_PHASE.Failed && (
|
|
||||||
<Stack gap="xs">
|
|
||||||
<ApiErrorAlert error={error ? new Error(error) : (intent?.status ?? 'Payment failed')} title="Payment failed" />
|
|
||||||
<Button onClick={retry} variant="light">Try again</Button>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
// Single source of truth for every literal used across the payment feature.
|
|
||||||
// Nothing outside this file should contain a payment-related magic string or number —
|
|
||||||
// grep for any of the values below and this file should be the only hit.
|
|
||||||
|
|
||||||
export const DEFAULT_PAYMENT_PROVIDER = 'TELEBIRR' as const;
|
|
||||||
|
|
||||||
export const PAYMENT_HISTORY_KEY = 'ema-payment-history'; // matches the repo's ema-portal-* key convention
|
|
||||||
export const PAYMENT_INTENT_QUERY_KEY = 'payment-intent'; // react-query cache namespace for GET /payments/intents/:id
|
|
||||||
|
|
||||||
export const POLL_INTERVAL_MS = 4_000; // contract: poll every 4s
|
|
||||||
export const REQUEST_TIMEOUT_MS = 15_000; // per-request abort, so one hung call can't stall the UI forever
|
|
||||||
export const SLOW_PAYMENT_MS = 5 * 60_000; // after this we show a "taking longer" note — polling itself never stops for it
|
|
||||||
|
|
||||||
export const PAYMENTS_ROUTE = '/payments';
|
|
||||||
|
|
||||||
// Phase names live with their type so there is one definition, not a const list plus a
|
|
||||||
// hand-written union that can drift apart.
|
|
||||||
export const PAYMENT_PHASE = {
|
|
||||||
Idle: 'idle',
|
|
||||||
Creating: 'creating',
|
|
||||||
Opening: 'opening',
|
|
||||||
Blocked: 'blocked',
|
|
||||||
Waiting: 'waiting',
|
|
||||||
Success: 'success',
|
|
||||||
Failed: 'failed',
|
|
||||||
} as const;
|
|
||||||
export type PaymentPhase = (typeof PAYMENT_PHASE)[keyof typeof PAYMENT_PHASE];
|
|
||||||
|
|
||||||
// The only place status strings are enumerated. isTerminalStatus/isSuccessStatus (api/payments.ts)
|
|
||||||
// read these sets; nothing else in the feature compares a status string directly.
|
|
||||||
// PAY_SUCCESS / PAY_FAILED are the literal `trade_status` values Telebirr puts on the return URL.
|
|
||||||
export const TERMINAL_STATUSES = new Set([
|
|
||||||
'SUCCESS',
|
|
||||||
'PAY_SUCCESS',
|
|
||||||
'FAILED',
|
|
||||||
'PAY_FAILED',
|
|
||||||
'CANCELLED',
|
|
||||||
'CANCELED',
|
|
||||||
'EXPIRED',
|
|
||||||
]);
|
|
||||||
export const SUCCESS_STATUSES = new Set(['SUCCESS', 'PAY_SUCCESS', 'COMPLETED']);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Both the Telebirr `returnUrl` and `failureUrl` point at the same landing page — it reads
|
|
||||||
* `trade_status` off the query string and branches, so one route serves both outcomes.
|
|
||||||
* Deriving both from PAYMENTS_ROUTE means the route only needs to change in one place.
|
|
||||||
*/
|
|
||||||
export function paymentReturnUrls(): { returnUrl: string; failureUrl: string } {
|
|
||||||
const url = `${window.location.origin}${PAYMENTS_ROUTE}`;
|
|
||||||
return { returnUrl: url, failureUrl: url };
|
|
||||||
}
|
|
||||||
@@ -1,249 +0,0 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import {
|
|
||||||
getIntent,
|
|
||||||
initiatePayment,
|
|
||||||
isSuccessStatus,
|
|
||||||
isTerminalStatus,
|
|
||||||
recordPayment,
|
|
||||||
updatePaymentStatus,
|
|
||||||
} from '../api/payments';
|
|
||||||
import {
|
|
||||||
PAYMENT_INTENT_QUERY_KEY,
|
|
||||||
PAYMENT_PHASE,
|
|
||||||
POLL_INTERVAL_MS,
|
|
||||||
SLOW_PAYMENT_MS,
|
|
||||||
paymentReturnUrls,
|
|
||||||
type PaymentPhase,
|
|
||||||
} from '../constants';
|
|
||||||
import type { PaymentIntent, PaymentProvider } from '../types/payment';
|
|
||||||
|
|
||||||
export interface UsePaymentFlowOptions {
|
|
||||||
opened: boolean;
|
|
||||||
title: string;
|
|
||||||
amountMinor: number;
|
|
||||||
currency: string;
|
|
||||||
referenceId: string;
|
|
||||||
orderRef: string;
|
|
||||||
service: string;
|
|
||||||
referenceType: string;
|
|
||||||
provider: PaymentProvider;
|
|
||||||
payerAccount?: string;
|
|
||||||
onSuccess?: (intent: PaymentIntent) => void;
|
|
||||||
onFailure?: (intent: PaymentIntent) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UsePaymentFlowResult {
|
|
||||||
phase: PaymentPhase;
|
|
||||||
intent: PaymentIntent | null;
|
|
||||||
error: string | null;
|
|
||||||
/** True once the modal has been waiting past SLOW_PAYMENT_MS — the poll keeps running regardless. */
|
|
||||||
isSlow: boolean;
|
|
||||||
/** clientAction.url, kept around so a blocked popup can be reopened from a user click. */
|
|
||||||
redirectUrl: string | null;
|
|
||||||
/** Starts a fresh attempt. Only meaningful from idle/failed/blocked — a no-op otherwise. */
|
|
||||||
pay: () => void;
|
|
||||||
/** Re-opens the Telebirr tab from an explicit user gesture (browsers require this — never automatic). */
|
|
||||||
openPayment: () => void;
|
|
||||||
/** Same as pay(), exposed under its own name for the "Try again" button's intent. */
|
|
||||||
retry: () => void;
|
|
||||||
/** One manual GET /payments/intents/:id, for the "Check again" button. */
|
|
||||||
refresh: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Owns the full lifecycle: initiate -> record history -> redirect -> poll -> fire onSuccess/onFailure
|
|
||||||
* exactly once -> retry. `phase` is the single value the modal renders from.
|
|
||||||
*/
|
|
||||||
export function usePaymentFlow(options: UsePaymentFlowOptions): UsePaymentFlowResult {
|
|
||||||
const {
|
|
||||||
opened,
|
|
||||||
title,
|
|
||||||
amountMinor,
|
|
||||||
currency,
|
|
||||||
referenceId,
|
|
||||||
orderRef,
|
|
||||||
service,
|
|
||||||
referenceType,
|
|
||||||
provider,
|
|
||||||
payerAccount,
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
const [phase, setPhase] = useState<PaymentPhase>(PAYMENT_PHASE.Idle);
|
|
||||||
const [intentId, setIntentId] = useState<string | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [redirectUrl, setRedirectUrl] = useState<string | null>(null);
|
|
||||||
const [startedAt, setStartedAt] = useState<number | null>(null);
|
|
||||||
|
|
||||||
// Callbacks kept in a ref, refreshed every render, so the fire-once effect below never closes
|
|
||||||
// over a stale onSuccess/onFailure prop.
|
|
||||||
const callbacksRef = useRef({ onSuccess: options.onSuccess, onFailure: options.onFailure });
|
|
||||||
callbacksRef.current = { onSuccess: options.onSuccess, onFailure: options.onFailure };
|
|
||||||
|
|
||||||
// Guards against firing onSuccess/onFailure more than once for the same intent.
|
|
||||||
const firedForRef = useRef<string | null>(null);
|
|
||||||
|
|
||||||
// Reopening the modal starts a fresh attempt rather than resuming a previous one — resuming would
|
|
||||||
// risk a second initiate. The documented recovery path for an in-flight payment is /payments,
|
|
||||||
// which reconciles the persisted history record instead.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!opened) {
|
|
||||||
setPhase(PAYMENT_PHASE.Idle);
|
|
||||||
setIntentId(null);
|
|
||||||
setError(null);
|
|
||||||
setRedirectUrl(null);
|
|
||||||
setStartedAt(null);
|
|
||||||
firedForRef.current = null;
|
|
||||||
}
|
|
||||||
}, [opened]);
|
|
||||||
|
|
||||||
// Poll — react-query, never setInterval. A hand-rolled interval would additionally need an
|
|
||||||
// overlap guard, a stale-closure guard, and a cancel-on-unmount guard; react-query gives all
|
|
||||||
// three for free, which is the whole reason it's used here over a manual timer.
|
|
||||||
const {
|
|
||||||
data: polledIntent,
|
|
||||||
refetch,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: [PAYMENT_INTENT_QUERY_KEY, intentId],
|
|
||||||
queryFn: () => getIntent(intentId as string),
|
|
||||||
enabled: opened && !!intentId,
|
|
||||||
staleTime: 0, // overrides AppProviders' global 5-minute staleTime — this data must never be "fresh enough to skip"
|
|
||||||
retry: 0, // the next 4s tick IS the retry; a query-level retry would double requests
|
|
||||||
refetchOnWindowFocus: false, // returning from the Telebirr tab must not trigger an extra request
|
|
||||||
// Re-evaluated against the latest fetched data on every settle, so polling stops on the exact
|
|
||||||
// tick the terminal status arrives — no request fires after it.
|
|
||||||
refetchInterval: (query) => (isTerminalStatus(query.state.data?.status) ? false : POLL_INTERVAL_MS),
|
|
||||||
// window.open(..., '_blank') hands focus to the Telebirr tab, so our tab goes background.
|
|
||||||
// react-query's default pauses background polling, which would stall confirmation detection
|
|
||||||
// for the entire duration the user is on Telebirr's page — the opposite of what's needed here.
|
|
||||||
refetchIntervalInBackground: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fire onSuccess/onFailure exactly once per intent, the moment a terminal status is observed.
|
|
||||||
// An unknown status is neither terminal nor success (isTerminalStatus/isSuccessStatus both
|
|
||||||
// default to false for it) — the poll simply keeps running and the raw value is shown as-is.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!polledIntent || !isTerminalStatus(polledIntent.status)) return;
|
|
||||||
if (firedForRef.current === polledIntent.id) return;
|
|
||||||
firedForRef.current = polledIntent.id;
|
|
||||||
|
|
||||||
updatePaymentStatus(polledIntent.id, polledIntent.status);
|
|
||||||
|
|
||||||
if (isSuccessStatus(polledIntent.status)) {
|
|
||||||
setPhase(PAYMENT_PHASE.Success);
|
|
||||||
callbacksRef.current.onSuccess?.(polledIntent);
|
|
||||||
} else {
|
|
||||||
setPhase(PAYMENT_PHASE.Failed);
|
|
||||||
callbacksRef.current.onFailure?.(polledIntent);
|
|
||||||
}
|
|
||||||
}, [polledIntent]);
|
|
||||||
|
|
||||||
const start = useCallback(() => {
|
|
||||||
// Defensive guard against a double-click producing two POST /payments/initiate calls — the
|
|
||||||
// modal also disables the Pay button while creating, but the hook doesn't rely on that alone.
|
|
||||||
if (phase === PAYMENT_PHASE.Creating || phase === PAYMENT_PHASE.Opening || phase === PAYMENT_PHASE.Waiting) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setError(null);
|
|
||||||
setPhase(PAYMENT_PHASE.Creating);
|
|
||||||
|
|
||||||
const { returnUrl, failureUrl } = paymentReturnUrls();
|
|
||||||
// A fresh idempotency key per attempt — required so a user retry after a failure creates a new
|
|
||||||
// payment attempt rather than resubmitting the failed one. Held only for the duration of this
|
|
||||||
// call; nothing above `start()` sees or reuses it.
|
|
||||||
const idempotencyKey = crypto.randomUUID();
|
|
||||||
|
|
||||||
initiatePayment({
|
|
||||||
service,
|
|
||||||
referenceType,
|
|
||||||
referenceId,
|
|
||||||
orderRef,
|
|
||||||
amountMinor,
|
|
||||||
currency,
|
|
||||||
provider,
|
|
||||||
payerAccount,
|
|
||||||
returnUrl,
|
|
||||||
failureUrl,
|
|
||||||
idempotencyKey,
|
|
||||||
platform: 'web',
|
|
||||||
})
|
|
||||||
.then((intent) => {
|
|
||||||
recordPayment({
|
|
||||||
intentId: intent.id,
|
|
||||||
title,
|
|
||||||
orderRef,
|
|
||||||
referenceId,
|
|
||||||
provider,
|
|
||||||
amountMinor,
|
|
||||||
currency,
|
|
||||||
status: intent.status,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
firedForRef.current = null;
|
|
||||||
setStartedAt(Date.now());
|
|
||||||
setPhase(PAYMENT_PHASE.Opening);
|
|
||||||
|
|
||||||
const clientAction = intent.clientAction;
|
|
||||||
if (clientAction?.type === 'REDIRECT' && clientAction.url) {
|
|
||||||
const win = window.open(clientAction.url, '_blank', 'noopener');
|
|
||||||
if (win) {
|
|
||||||
setPhase(PAYMENT_PHASE.Waiting);
|
|
||||||
} else {
|
|
||||||
// Popup blocked: keep the intent alive and keep polling (it's gated on intentId, not
|
|
||||||
// phase) so a payment completed another way is still picked up. Only a user click on
|
|
||||||
// "Open Payment" retries window.open — browsers require a user gesture for that anyway.
|
|
||||||
setRedirectUrl(clientAction.url);
|
|
||||||
setPhase(PAYMENT_PHASE.Blocked);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Undocumented/absent clientAction type: poll anyway, since the service may complete
|
|
||||||
// server-side. Never invents an OTP screen, never hangs silently.
|
|
||||||
setPhase(PAYMENT_PHASE.Waiting);
|
|
||||||
}
|
|
||||||
|
|
||||||
setIntentId(intent.id);
|
|
||||||
})
|
|
||||||
.catch((err: unknown) => {
|
|
||||||
setError(err instanceof Error ? err.message : 'Could not start the payment.');
|
|
||||||
setPhase(PAYMENT_PHASE.Failed);
|
|
||||||
});
|
|
||||||
}, [
|
|
||||||
amountMinor,
|
|
||||||
currency,
|
|
||||||
orderRef,
|
|
||||||
payerAccount,
|
|
||||||
phase,
|
|
||||||
provider,
|
|
||||||
referenceId,
|
|
||||||
referenceType,
|
|
||||||
service,
|
|
||||||
title,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const openPayment = useCallback(() => {
|
|
||||||
if (!redirectUrl) return;
|
|
||||||
const win = window.open(redirectUrl, '_blank', 'noopener');
|
|
||||||
if (win) setPhase(PAYMENT_PHASE.Waiting);
|
|
||||||
}, [redirectUrl]);
|
|
||||||
|
|
||||||
const refresh = useCallback(() => {
|
|
||||||
void refetch();
|
|
||||||
}, [refetch]);
|
|
||||||
|
|
||||||
const isSlow =
|
|
||||||
phase === PAYMENT_PHASE.Waiting && startedAt !== null && Date.now() - startedAt > SLOW_PAYMENT_MS;
|
|
||||||
|
|
||||||
return {
|
|
||||||
phase,
|
|
||||||
intent: polledIntent ?? null,
|
|
||||||
error,
|
|
||||||
isSlow,
|
|
||||||
redirectUrl,
|
|
||||||
pay: start,
|
|
||||||
retry: start,
|
|
||||||
openPayment,
|
|
||||||
refresh,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { useSearchParams } from 'react-router-dom';
|
|
||||||
import { Alert, Badge, Container, Group, Paper, Stack, Table, Text, Title } from '@mantine/core';
|
|
||||||
import { IconCheck, IconInfoCircle } from '@tabler/icons-react';
|
|
||||||
import { isSuccessStatus, reconcilePaymentHistory } from '../api/payments';
|
|
||||||
import { formatMinor } from '../utils/money';
|
|
||||||
import type { PaymentHistoryRecord, PaymentStatus } from '../types/payment';
|
|
||||||
|
|
||||||
// Status -> colour has exactly one consumer (this table), so it stays inline rather than becoming
|
|
||||||
// a shared PaymentStatusBadge component.
|
|
||||||
function statusColor(status: PaymentStatus): string {
|
|
||||||
if (isSuccessStatus(status)) return 'teal';
|
|
||||||
const upper = status.toUpperCase();
|
|
||||||
if (upper === 'FAILED' || upper === 'PAY_FAILED' || upper === 'CANCELLED' || upper === 'CANCELED') return 'red';
|
|
||||||
if (upper === 'EXPIRED') return 'gray';
|
|
||||||
return 'blue';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Landing page for Telebirr's returnUrl/failureUrl. Reads the five documented query params,
|
|
||||||
* reconciles the one relevant history record against the backend, then renders the outcome —
|
|
||||||
* trade_status === "PAY_SUCCESS" takes precedence, otherwise the backend's own status is shown.
|
|
||||||
*/
|
|
||||||
export function PaymentsPage() {
|
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
const [history, setHistory] = useState<PaymentHistoryRecord[]>([]);
|
|
||||||
const [reconciled, setReconciled] = useState(false);
|
|
||||||
|
|
||||||
const tradeStatus = searchParams.get('trade_status');
|
|
||||||
const totalAmount = searchParams.get('total_amount');
|
|
||||||
const transCurrency = searchParams.get('trans_currency');
|
|
||||||
const merchOrderId = searchParams.get('merch_order_id');
|
|
||||||
const paymentOrderId = searchParams.get('payment_order_id');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Only ever reconcile once on load, against the orderRef captured from the initial URL —
|
|
||||||
// this page is not a live dashboard, so `merchOrderId` is deliberately not a dependency here.
|
|
||||||
// (The react-hooks/exhaustive-deps rule isn't configured in this repo's eslint setup, so no
|
|
||||||
// disable directive is needed — this comment documents the omission instead.)
|
|
||||||
reconcilePaymentHistory(merchOrderId ?? undefined)
|
|
||||||
.then(setHistory)
|
|
||||||
.finally(() => setReconciled(true));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const record = history.find((r) => r.orderRef === merchOrderId);
|
|
||||||
const isSuccess = tradeStatus === 'PAY_SUCCESS';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Container size="sm" py="xl">
|
|
||||||
<Stack gap="lg">
|
|
||||||
<Title order={2}>Payment Status</Title>
|
|
||||||
|
|
||||||
{reconciled && (
|
|
||||||
<Paper withBorder radius="lg" p="lg">
|
|
||||||
{isSuccess ? (
|
|
||||||
<Alert color="teal" icon={<IconCheck size={16} />} title="Payment successful">
|
|
||||||
<Stack gap={4}>
|
|
||||||
{totalAmount && (
|
|
||||||
<Text fz="sm">
|
|
||||||
Amount: {totalAmount} {transCurrency ?? ''}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
{paymentOrderId && <Text fz="xs" c="dimmed">Order: {paymentOrderId}</Text>}
|
|
||||||
</Stack>
|
|
||||||
</Alert>
|
|
||||||
) : (
|
|
||||||
<Alert color="blue" icon={<IconInfoCircle size={16} />} title="Payment status">
|
|
||||||
<Stack gap={4}>
|
|
||||||
<Group gap="xs">
|
|
||||||
<Text fz="sm">Latest status:</Text>
|
|
||||||
<Badge color={statusColor(record?.status ?? tradeStatus ?? 'UNKNOWN')} variant="light">
|
|
||||||
{record?.status ?? tradeStatus ?? 'UNKNOWN'}
|
|
||||||
</Badge>
|
|
||||||
</Group>
|
|
||||||
{totalAmount && (
|
|
||||||
<Text fz="sm">
|
|
||||||
Amount: {totalAmount} {transCurrency ?? ''}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
</Paper>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Paper withBorder radius="lg" p="lg">
|
|
||||||
<Text fw={600} mb="sm">Payment History</Text>
|
|
||||||
{history.length === 0 ? (
|
|
||||||
<Text fz="sm" c="dimmed">No payments recorded on this device yet.</Text>
|
|
||||||
) : (
|
|
||||||
<Table striped highlightOnHover>
|
|
||||||
<Table.Thead>
|
|
||||||
<Table.Tr>
|
|
||||||
<Table.Th>Title</Table.Th>
|
|
||||||
<Table.Th>Order Ref</Table.Th>
|
|
||||||
<Table.Th>Amount</Table.Th>
|
|
||||||
<Table.Th>Status</Table.Th>
|
|
||||||
<Table.Th>Date</Table.Th>
|
|
||||||
</Table.Tr>
|
|
||||||
</Table.Thead>
|
|
||||||
<Table.Tbody>
|
|
||||||
{[...history].reverse().map((r) => (
|
|
||||||
<Table.Tr key={r.intentId}>
|
|
||||||
<Table.Td>{r.title}</Table.Td>
|
|
||||||
<Table.Td>{r.orderRef}</Table.Td>
|
|
||||||
<Table.Td>{formatMinor(r.amountMinor, r.currency)}</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Badge color={statusColor(r.status)} variant="light">{r.status}</Badge>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>{new Date(r.createdAt).toLocaleString()}</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
))}
|
|
||||||
</Table.Tbody>
|
|
||||||
</Table>
|
|
||||||
)}
|
|
||||||
</Paper>
|
|
||||||
</Stack>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
// Shared payment types. Every request/response shape the feature touches is declared here —
|
|
||||||
// no `any`, no inline object literals typed ad hoc at the call site.
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One provider today (Telebirr only). Kept as its own union — rather than a bare string —
|
|
||||||
* so that adding a second provider later is a one-line widen here; every function and prop
|
|
||||||
* that carries `provider` already threads it through, so no signatures need to change.
|
|
||||||
*/
|
|
||||||
export type PaymentProvider = 'TELEBIRR';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* OPEN union, deliberately. A closed union would compile-time-guarantee something the backend
|
|
||||||
* does not guarantee: the first new status the payment service ships would either fail to compile
|
|
||||||
* everywhere or force `as PaymentStatus` casts at every boundary, silently defeating the point of
|
|
||||||
* isTerminalStatus()/isSuccessStatus(). `(string & {})` keeps autocomplete on the known set while
|
|
||||||
* still accepting anything the server actually sends, so an unknown status stays representable
|
|
||||||
* instead of being cast away or crashing.
|
|
||||||
*/
|
|
||||||
export type KnownPaymentStatus =
|
|
||||||
| 'PENDING'
|
|
||||||
| 'INITIATED'
|
|
||||||
| 'PROCESSING'
|
|
||||||
| 'SUCCESS'
|
|
||||||
| 'FAILED'
|
|
||||||
| 'CANCELLED'
|
|
||||||
| 'EXPIRED';
|
|
||||||
export type PaymentStatus = KnownPaymentStatus | (string & {});
|
|
||||||
|
|
||||||
export interface PaymentClientAction {
|
|
||||||
/** Only "REDIRECT" is handled today; anything else is displayed, never silently dropped. */
|
|
||||||
type: 'REDIRECT' | (string & {});
|
|
||||||
url?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returned by POST /payments/initiate and GET /payments/intents/:intentId. */
|
|
||||||
export interface PaymentIntent {
|
|
||||||
id: string;
|
|
||||||
status: PaymentStatus;
|
|
||||||
provider: PaymentProvider;
|
|
||||||
amountMinor: number;
|
|
||||||
currency: string;
|
|
||||||
orderRef?: string;
|
|
||||||
referenceId?: string;
|
|
||||||
clientAction?: PaymentClientAction;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Body for POST /payments/initiate. */
|
|
||||||
export interface PaymentRequest {
|
|
||||||
/** No service enum exists in this repo yet — see plan's open question on the exact values a caller should pass. */
|
|
||||||
service: string;
|
|
||||||
referenceType: string;
|
|
||||||
referenceId: string;
|
|
||||||
orderRef: string;
|
|
||||||
amountMinor: number;
|
|
||||||
currency: string;
|
|
||||||
provider: PaymentProvider;
|
|
||||||
/** Optional in the contract — Telebirr's own H5 page collects the payer's phone number. */
|
|
||||||
payerAccount?: string;
|
|
||||||
returnUrl: string;
|
|
||||||
failureUrl: string;
|
|
||||||
idempotencyKey: string;
|
|
||||||
platform: 'web';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Body for POST /payments/intents/:intentId/confirm. */
|
|
||||||
export interface PaymentConfirmRequest {
|
|
||||||
otp: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One row of the localStorage payment history. */
|
|
||||||
export interface PaymentHistoryRecord {
|
|
||||||
intentId: string;
|
|
||||||
title: string;
|
|
||||||
orderRef: string;
|
|
||||||
referenceId: string;
|
|
||||||
provider: PaymentProvider;
|
|
||||||
amountMinor: number;
|
|
||||||
currency: string;
|
|
||||||
status: PaymentStatus;
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
/** Whole-birr major amount -> minor units (cents). Rounds to avoid float drift (19.99 -> 1999). */
|
|
||||||
export function toMinor(major: number): number {
|
|
||||||
return Math.round(major * 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fromMinor(minor: number): number {
|
|
||||||
return minor / 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Matches the format already used across the portal (`ETB ${n.toFixed(2)}`, e.g.
|
|
||||||
* SeamanBookApplicationPage.tsx) rather than Intl.NumberFormat({style:'currency'}), whose ETB
|
|
||||||
* output varies by ICU build. Not unifying the ~20 existing inline call sites — out of scope here.
|
|
||||||
*/
|
|
||||||
export function formatMinor(amountMinor: number, currency: string): string {
|
|
||||||
return `${currency} ${fromMinor(amountMinor).toLocaleString(undefined, {
|
|
||||||
minimumFractionDigits: 2,
|
|
||||||
maximumFractionDigits: 2,
|
|
||||||
})}`;
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
Loader,
|
Loader,
|
||||||
Center,
|
Center,
|
||||||
Paper,
|
Paper,
|
||||||
Badge,
|
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { IconRefresh, IconAdjustmentsHorizontal , IconInbox, } from "@tabler/icons-react";
|
import { IconRefresh, IconAdjustmentsHorizontal , IconInbox, } from "@tabler/icons-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -24,6 +23,8 @@ export interface AdvancedColumn<T> {
|
|||||||
align?: "left" | "center" | "right";
|
align?: "left" | "center" | "right";
|
||||||
/** Whether column starts visible. Default true. */
|
/** Whether column starts visible. Default true. */
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
/** Label for the View menu; falls back to `header` when it is a plain string. */
|
||||||
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AdvancedTableProps<T> {
|
interface AdvancedTableProps<T> {
|
||||||
@@ -90,12 +91,6 @@ export function AdvancedTable<T extends { id?: string | number }>({
|
|||||||
<Text fw={600}>{""}</Text>
|
<Text fw={600}>{""}</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<Menu
|
|
||||||
closeOnItemClick={false}
|
|
||||||
shadow="md"
|
|
||||||
position="bottom-end"
|
|
||||||
width={220}
|
|
||||||
>
|
|
||||||
{refresh && (
|
{refresh && (
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
@@ -107,6 +102,12 @@ export function AdvancedTable<T extends { id?: string | number }>({
|
|||||||
{t("common.refresh", "Refresh")}
|
{t("common.refresh", "Refresh")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
<Menu
|
||||||
|
closeOnItemClick={false}
|
||||||
|
shadow="md"
|
||||||
|
position="bottom-end"
|
||||||
|
width={220}
|
||||||
|
>
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
@@ -120,10 +121,16 @@ export function AdvancedTable<T extends { id?: string | number }>({
|
|||||||
<Menu.Label>
|
<Menu.Label>
|
||||||
{t("common.toggleColumns", "Toggle columns")}
|
{t("common.toggleColumns", "Toggle columns")}
|
||||||
</Menu.Label>
|
</Menu.Label>
|
||||||
{columns.map((col, i) => (
|
{columns.map((col, i) => {
|
||||||
|
// A ReactNode header (e.g. a live checkbox or a clickable sort
|
||||||
|
// control) can't be reused as a menu-item label — skip it
|
||||||
|
// rather than nesting interactive markup inside the label.
|
||||||
|
const label = col.label ?? (typeof col.header === "string" ? col.header : null);
|
||||||
|
if (label === null) return null;
|
||||||
|
return (
|
||||||
<Menu.Item key={i} onClick={() => toggleColumn(i)}>
|
<Menu.Item key={i} onClick={() => toggleColumn(i)}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
label={col.header}
|
label={label}
|
||||||
checked={visible[i] ?? true}
|
checked={visible[i] ?? true}
|
||||||
disabled={
|
disabled={
|
||||||
visible.filter(Boolean).length === 1 &&
|
visible.filter(Boolean).length === 1 &&
|
||||||
@@ -137,7 +144,8 @@ export function AdvancedTable<T extends { id?: string | number }>({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -17,6 +17,22 @@ export function useServerTable({ pageSize = 10 }: UseServerTableOptions = {}) {
|
|||||||
setPageIndex(0);
|
setPageIndex(0);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Slice an already-fetched array for AdvancedTable when the endpoint has no
|
||||||
|
// skip/take of its own. Clamps pageIndex so deleting the last row of the
|
||||||
|
// last page doesn't strand the table on an empty slice.
|
||||||
|
const paginate = useCallback(
|
||||||
|
<T,>(rows: T[]) => {
|
||||||
|
const lastPage = Math.max(0, Math.ceil(rows.length / pageSize) - 1);
|
||||||
|
const clamped = Math.min(pageIndex, lastPage);
|
||||||
|
return {
|
||||||
|
rows: rows.slice(clamped * pageSize, clamped * pageSize + pageSize),
|
||||||
|
pageIndex: clamped,
|
||||||
|
itemCount: rows.length,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[pageIndex, pageSize],
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
pageIndex,
|
pageIndex,
|
||||||
setPageIndex,
|
setPageIndex,
|
||||||
@@ -25,5 +41,6 @@ export function useServerTable({ pageSize = 10 }: UseServerTableOptions = {}) {
|
|||||||
pageSize,
|
pageSize,
|
||||||
skip: pageIndex * pageSize,
|
skip: pageIndex * pageSize,
|
||||||
take: pageSize,
|
take: pageSize,
|
||||||
|
paginate,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user