diff --git a/apps/backoffice/src/app/features/certification/pages/CertificationPage.tsx b/apps/backoffice/src/app/features/certification/pages/CertificationPage.tsx
index a11742cb6..7d2547dd0 100644
--- a/apps/backoffice/src/app/features/certification/pages/CertificationPage.tsx
+++ b/apps/backoffice/src/app/features/certification/pages/CertificationPage.tsx
@@ -4,7 +4,6 @@ import {
Title,
Group,
Button,
- Table,
Badge,
ActionIcon,
Modal,
@@ -12,14 +11,13 @@ import {
TextInput,
Textarea,
Paper,
- Loader,
- Center,
+ Card,
Alert,
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { useTranslation } from 'react-i18next';
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 {
useGetCertificationsQuery,
useCreateCertificationMutation,
@@ -76,7 +74,8 @@ export function CertificationPage() {
const { t, i18n } = useTranslation();
const locale = i18n.language as 'en' | 'am';
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 [updateCert, { isLoading: isUpdating }] = useUpdateCertificationMutation();
const [deleteCert] = useDeleteCertificationMutation();
@@ -122,9 +121,44 @@ export function CertificationPage() {
}
};
- if (isLoading) return
;
if (isError) return } color="red" title={t('certification.loadError')} />;
+ const columns: AdvancedColumn[] = [
+ {
+ header: t('certification.columns.name'),
+ cell: ({ row }) => {row.original.name[locale]},
+ },
+ {
+ header: t('certification.columns.description'),
+ cell: ({ row }) => {row.original.description[locale]},
+ },
+ {
+ header: t('certification.columns.status'),
+ cell: ({ row }) => (
+
+ {row.original.isActive ? t('certification.status.active') : t('certification.status.inactive')}
+
+ ),
+ },
+ {
+ header: '',
+ label: t('certification.columns.actions', 'Actions'),
+ size: 90,
+ cell: ({ row }) => (
+
+ { setEditing(row.original); setShowForm(true); }}>
+
+
+ { setDeleteTarget(row.original); openDelete(); }}>
+
+
+
+ ),
+ },
+ ];
+
+ const page = paginate(certifications);
+
return (
@@ -148,50 +182,20 @@ export function CertificationPage() {
/>
)}
-
-
-
-
- {t('certification.columns.name')}
- {t('certification.columns.description')}
- {t('certification.columns.status')}
-
-
-
-
- {certifications.map((cert) => (
-
- {cert.name[locale]}
-
- {cert.description[locale]}
-
-
-
- {cert.isActive ? t('certification.status.active') : t('certification.status.inactive')}
-
-
-
-
- { setEditing(cert); setShowForm(true); }}>
-
-
- { setDeleteTarget(cert); openDelete(); }}>
-
-
-
-
-
- ))}
- {certifications.length === 0 && (
-
-
- {t('certification.noItems')}
-
-
- )}
-
-
-
+
+
+
{t('certification.deleteConfirmText', { name: deleteTarget?.name?.[locale] })}
diff --git a/apps/backoffice/src/app/features/exam/pages/ExamPage.tsx b/apps/backoffice/src/app/features/exam/pages/ExamPage.tsx
index a84cfc60a..284c991eb 100644
--- a/apps/backoffice/src/app/features/exam/pages/ExamPage.tsx
+++ b/apps/backoffice/src/app/features/exam/pages/ExamPage.tsx
@@ -5,7 +5,6 @@ import {
Title,
Group,
Button,
- Table,
Badge,
ActionIcon,
Modal,
@@ -13,8 +12,7 @@ import {
TextInput,
Textarea,
Paper,
- Loader,
- Center,
+ Card,
Alert,
Select,
NumberInput,
@@ -33,7 +31,7 @@ import {
IconClipboardList,
IconDetails,
} 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 {
useGetExamsQuery,
@@ -356,7 +354,8 @@ export function ExamPage() {
const { handleError } = useErrorHandler();
const locale = i18n.language as "en" | "am";
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 [updateExam, { isLoading: isUpdating }] = useUpdateExamMutation();
const [deleteExam] = useDeleteExamMutation();
@@ -431,12 +430,6 @@ export function ExamPage() {
}
};
- if (isLoading)
- return (
-
-
-
- );
if (isError)
return (
);
+ const columns: AdvancedColumn[] = [
+ {
+ header: t("exam.columns.title"),
+ cell: ({ row }) => (
+ navigate(`/exams/${row.original.id}`)}
+ >
+ {row.original.title[locale]}
+
+ ),
+ },
+ {
+ header: t("exam.columns.certification"),
+ cell: ({ row }) => {getCertName(row.original.certificationId)},
+ },
+ {
+ header: t("exam.columns.date"),
+ cell: ({ row }) => {row.original.date},
+ },
+ {
+ header: t("exam.columns.type"),
+ cell: ({ row }) => (
+
+ {t(`exam.type.${row.original.type}`)}
+
+ ),
+ },
+ {
+ header: t("exam.columns.form"),
+ cell: ({ row }) => (
+
+ {t(`exam.formType.${row.original.form}`)}
+
+ ),
+ },
+ {
+ header: t("exam.columns.venue"),
+ cell: ({ row }) => {row.original.venue},
+ },
+ {
+ header: t("exam.columns.questions"),
+ cell: ({ row }) => (
+
+ {row.original.questions?.length ?? 0}
+
+ ),
+ },
+ {
+ header: t("exam.columns.status"),
+ cell: ({ row }) => (
+
+ {t(`exam.status.${row.original.status}`)}
+
+ ),
+ },
+ {
+ header: t("exam.columns.actions"),
+ align: "right",
+ cell: ({ row }) => (
+
+ {
+ setEditing(row.original);
+ setShowForm(true);
+ }}
+ >
+
+
+ {
+ setDeleteTarget(row.original);
+ openDelete();
+ }}
+ >
+
+
+ {
+ navigate(`/exams/${row.original.id}`);
+ }}
+ >
+
+
+
+ ),
+ },
+ ];
+
+ const page = paginate(exams);
+
return (
@@ -477,127 +573,20 @@ export function ExamPage() {
/>
)}
-
-
-
-
- {t("exam.columns.title")}
- {t("exam.columns.certification")}
- {t("exam.columns.date")}
- {t("exam.columns.type")}
- {t("exam.columns.form")}
- {t("exam.columns.venue")}
- {t("exam.columns.questions")}
- {t("exam.columns.status")}
- {t("exam.columns.actions")}
-
-
-
-
- {exams.map((exam) => (
-
-
- navigate(`/exams/${exam.id}`)}
- >
- {exam.title[locale]}
-
-
-
- {getCertName(exam.certificationId)}
-
-
- {exam.date}
-
-
-
- {t(`exam.type.${exam.type}`)}
-
-
-
-
- {t(`exam.formType.${exam.form}`)}
-
-
-
- {exam.venue}
-
-
-
- {exam.questions?.length ?? 0}
-
-
-
-
- {t(`exam.status.${exam.status}`)}
-
-
-
-
- {
- setEditing(exam);
- setShowForm(true);
- }}
- >
-
-
- {
- setDeleteTarget(exam);
- openDelete();
- }}
- >
-
-
- {
- navigate(`/exams/${exam.id}`);
- }}
- >
-
-
-
-
-
- ))}
- {exams.length === 0 && (
-
-
-
- {t("exam.noItems")}
-
-
-
- )}
-
-
-
+
+
+
{/* Delete confirmation */}
(
{row.original.applicationNumber}
@@ -330,6 +331,7 @@ export function LicenseQueuePage() {
},
{
header: sortableHeader(t('queue.company', 'Company'), 'companyName'),
+ label: t('queue.company', 'Company'),
cell: ({ row }) => {row.original.companyName ?? '—'},
},
{
@@ -346,6 +348,7 @@ export function LicenseQueuePage() {
},
{
header: sortableHeader(t('queue.statusCol', 'Status'), 'status'),
+ label: t('queue.statusCol', 'Status'),
cell: ({ row }) => (
{STATUS_LABELS[row.original.status]}
@@ -354,6 +357,7 @@ export function LicenseQueuePage() {
},
{
header: sortableHeader(t('queue.submitted', 'Submitted'), 'submittedAt'),
+ label: t('queue.submitted', 'Submitted'),
cell: ({ row }) => (
{row.original.submittedAt
@@ -378,6 +382,7 @@ export function LicenseQueuePage() {
},
{
header: '',
+ label: t('queue.actionsColumn', 'Actions'),
align: 'right',
size: 140,
cell: ({ row }) =>
diff --git a/apps/backoffice/src/app/features/question/pages/QuestionPage.tsx b/apps/backoffice/src/app/features/question/pages/QuestionPage.tsx
index 8f1ec0486..aa7210240 100644
--- a/apps/backoffice/src/app/features/question/pages/QuestionPage.tsx
+++ b/apps/backoffice/src/app/features/question/pages/QuestionPage.tsx
@@ -4,15 +4,13 @@ import {
Title,
Group,
Button,
- Table,
Badge,
ActionIcon,
Modal,
Text,
TextInput,
Paper,
- Loader,
- Center,
+ Card,
Alert,
Select,
NumberInput,
@@ -20,7 +18,7 @@ import {
import { useDisclosure } from '@mantine/hooks';
import { useTranslation } from 'react-i18next';
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 {
useGetQuestionsQuery,
@@ -104,7 +102,8 @@ export function QuestionPage() {
const locale = i18n.language as 'en' | 'am';
const { handleError } = useErrorHandler();
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 [updateQ, { isLoading: isUpdating }] = useUpdateQuestionMutation();
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 filtered = questions.filter((q) => !certFilter || q.certificationId === certFilter);
+ const page = paginate(filtered);
const getCertName = (id: string) => certifications.find((c) => c.id === id)?.name?.[locale] ?? '-';
@@ -158,9 +158,53 @@ export function QuestionPage() {
}
};
- if (isLoading) return ;
if (isError) return } color="red" title={t('question.loadError')} />;
+ const columns: AdvancedColumn[] = [
+ {
+ header: t('question.columns.title'),
+ cell: ({ row }) => {row.original.title[locale]},
+ },
+ {
+ header: t('question.columns.certification'),
+ cell: ({ row }) => {getCertName(row.original.certificationId)},
+ },
+ {
+ header: t('question.columns.form'),
+ cell: ({ row }) => (
+
+ {t(`question.form.${row.original.form === 'ESSAY' ? 'essay' : 'choice'}`)}
+
+ ),
+ },
+ {
+ header: t('question.columns.points'),
+ cell: ({ row }) => {row.original.points},
+ },
+ {
+ header: t('question.columns.status'),
+ cell: ({ row }) => (
+
+ {row.original.isActive ? t('question.status.active') : t('question.status.inactive')}
+
+ ),
+ },
+ {
+ header: '',
+ label: t('question.columns.actions', 'Actions'),
+ cell: ({ row }) => (
+
+ { setEditing(row.original); setShowForm(true); }}>
+
+
+ { setDeleteTarget(row.original); openDelete(); }}>
+
+
+
+ ),
+ },
+ ];
+
return (
@@ -182,54 +226,32 @@ export function QuestionPage() {
/>
)}
-
+
{t('question.pool')}
-
-
-
-
- {t('question.columns.title')}
- {t('question.columns.certification')}
- {t('question.columns.form')}
- {t('question.columns.points')}
- {t('question.columns.status')}
-
-
-
-
- {filtered.map((q) => (
-
- {q.title[locale]}
- {getCertName(q.certificationId)}
- {t(`question.form.${q.form === 'ESSAY' ? 'essay' : 'choice'}`)}
- {q.points}
-
- {q.isActive ? t('question.status.active') : t('question.status.inactive')}
-
-
-
- { setEditing(q); setShowForm(true); }}>
-
-
- { setDeleteTarget(q); openDelete(); }}>
-
-
-
-
-
- ))}
- {filtered.length === 0 && (
-
-
- {t('question.noQuestions')}
-
-
- )}
-
-
-
+
+
{t('question.deleteConfirmText')}
diff --git a/apps/backoffice/src/app/features/result/pages/ResultPage.tsx b/apps/backoffice/src/app/features/result/pages/ResultPage.tsx
index 5094a148f..5845ccda7 100644
--- a/apps/backoffice/src/app/features/result/pages/ResultPage.tsx
+++ b/apps/backoffice/src/app/features/result/pages/ResultPage.tsx
@@ -9,6 +9,7 @@ import {
Modal,
Text,
Paper,
+ Card,
Loader,
Center,
Alert,
@@ -35,7 +36,7 @@ import {
IconChartBar,
IconSearch,
} 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 { useGetResultsQuery, useLazyGetResultQuery, useDeleteResultMutation, useUpdateResultMutation } from '../api/result-api';
import { useGetExamsQuery } from '../../exam/api/exam-api';
@@ -97,7 +98,8 @@ export function ResultPage() {
const locale = i18n.language as 'en' | 'am';
const { handleError } = useErrorHandler();
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 exams = examRes?.items ?? [];
@@ -207,9 +209,76 @@ export function ResultPage() {
}
};
- if (isLoading) return ;
if (isError) return } color="red" title={t('result.loadError')} />;
+ const columns: AdvancedColumn[] = [
+ {
+ header: t('result.columns.seafarer'),
+ cell: ({ row }) => (
+
+ {row.original.seafarer
+ ? `${row.original.seafarer.firstName} ${row.original.seafarer.lastName}`
+ : row.original.seafarerId.slice(0, 8)}
+
+ ),
+ },
+ {
+ header: t('result.columns.exam'),
+ cell: ({ row }) => (
+ {row.original.exam ? row.original.exam.title[locale] : getExamTitle(row.original.examId)}
+ ),
+ },
+ {
+ header: t('result.columns.totalScore'),
+ cell: ({ row }) => {row.original.totalScore},
+ },
+ {
+ header: t('result.columns.status'),
+ cell: ({ row }) => (
+
+ }
+ >
+ {t(`result.status.${row.original.status}`)}
+
+ ),
+ },
+ {
+ header: t('result.columns.date'),
+ cell: ({ row }) => {new Date(row.original.createdAt).toLocaleDateString()},
+ },
+ {
+ header: '',
+ label: t('result.columns.actions', 'Actions'),
+ cell: ({ row }) => (
+
+ } onClick={() => viewDetail(row.original)}>
+ {t('result.action.viewEdit')}
+
+ }
+ onClick={() => { setDeleteTarget(row.original); openDelete(); }}
+ >
+ {t('result.action.delete')}
+
+
+ ),
+ },
+ ];
+
+ const page = paginate(filtered);
+
return (
@@ -229,7 +298,7 @@ export function ResultPage() {
-
+
{t('result.section')}
@@ -237,7 +306,7 @@ export function ResultPage() {
placeholder={t('result.search.seafarer')}
leftSection={}
value={searchQuery}
- onChange={(e) => setSearchQuery(e.currentTarget.value)}
+ onChange={(e) => { setSearchQuery(e.currentTarget.value); setPageIndex(0); }}
size="sm"
style={{ width: 240 }}
/>
@@ -245,7 +314,7 @@ export function ResultPage() {
placeholder={t('result.search.filterByExam')}
data={[{ value: '', label: t('result.search.allExams') }, ...examOptions]}
value={examFilter}
- onChange={(v) => setExamFilter(v ?? null)}
+ onChange={(v) => { setExamFilter(v ?? null); setPageIndex(0); }}
size="sm"
style={{ width: 280 }}
clearable
@@ -253,77 +322,19 @@ export function ResultPage() {
-
-
-
- {t('result.columns.seafarer')}
- {t('result.columns.exam')}
- {t('result.columns.totalScore')}
- {t('result.columns.status')}
- {t('result.columns.date')}
-
-
-
-
- {filtered.map((r) => (
-
-
-
- {r.seafarer ? `${r.seafarer.firstName} ${r.seafarer.lastName}` : r.seafarerId.slice(0, 8)}
-
-
- {r.exam ? r.exam.title[locale] : getExamTitle(r.examId)}
- {r.totalScore}
-
-
- }
- >
- {t(`result.status.${r.status}`)}
-
-
- {new Date(r.createdAt).toLocaleDateString()}
-
-
- }
- onClick={() => viewDetail(r)}
- >
- {t('result.action.viewEdit')}
-
- }
- onClick={() => { setDeleteTarget(r); openDelete(); }}
- >
- {t('result.action.delete')}
-
-
-
-
- ))}
- {filtered.length === 0 && (
-
-
- {t('result.noItems')}
-
-
- )}
-
-
-
+
+
({
+ const { data, isFetching, refetch } = useApiQuery<{ total: number; items: ProfileRow[] }>({
url: '/profiles',
method: 'GET',
params: { q: 'i=profession,address&t=200' },
});
+ const { setPageIndex, pageSize, paginate } = useServerTable({ pageSize: 25 });
const items = (data?.items ?? []).filter((p) => {
if (!search.trim()) return true;
@@ -47,6 +46,38 @@ export function SeafarerRegistryPage() {
.filter(Boolean)
.some((v) => String(v).toLowerCase().includes(term));
});
+ const page = paginate(items);
+
+ const columns: AdvancedColumn[] = [
+ {
+ header: 'Name',
+ cell: ({ row }) => (
+
+ {[row.original.firstName, row.original.middleName, row.original.lastName].filter(Boolean).join(' ')}
+
+ ),
+ },
+ {
+ header: 'Profession',
+ cell: ({ row }) => {row.original.profession?.name?.en ?? '—'},
+ },
+ {
+ header: 'ID number',
+ cell: ({ row }) => {row.original.address?.idNumber ?? '—'},
+ },
+ {
+ header: 'Phone',
+ cell: ({ row }) => {row.original.address?.primaryPhoneNumber ?? '—'},
+ },
+ {
+ header: 'Status',
+ cell: ({ row }) => (
+
+ {row.original.isComplete ? 'Complete' : 'Incomplete'}
+
+ ),
+ },
+ ];
return (
@@ -61,64 +92,24 @@ export function SeafarerRegistryPage() {
placeholder="Name or ID number"
leftSection={}
value={search}
- onChange={(e) => setSearch(e.currentTarget.value)}
+ onChange={(e) => { setSearch(e.currentTarget.value); setPageIndex(0); }}
w={260}
/>
- {isLoading ? (
-
-
-
- ) : items.length === 0 ? (
-
-
- {search ? 'No profiles match that search.' : 'No seafarers registered yet.'}
-
-
- ) : (
-
-
-
- Name
- Profession
- ID number
- Phone
- Status
-
-
-
- {items.map((p) => (
-
-
-
- {[p.firstName, p.middleName, p.lastName].filter(Boolean).join(' ')}
-
-
-
- {p.profession?.name?.en ?? '—'}
-
-
-
- {p.address?.idNumber ?? '—'}
-
-
-
-
- {p.address?.primaryPhoneNumber ?? '—'}
-
-
-
-
- {p.isComplete ? 'Complete' : 'Incomplete'}
-
-
-
- ))}
-
-
- )}
+
);
diff --git a/apps/backoffice/src/app/i18n/locales/am.ts b/apps/backoffice/src/app/i18n/locales/am.ts
index a2949e7cb..0eaa66502 100644
--- a/apps/backoffice/src/app/i18n/locales/am.ts
+++ b/apps/backoffice/src/app/i18n/locales/am.ts
@@ -98,6 +98,10 @@ export const am: Translations = {
collapse: 'ሰብስብ',
expand: 'ዘርጋ',
toggleTheme: 'የብርሃን / ጨለማ ሁነታን ይቀይሩ',
+ refresh: 'አድስ',
+ view: 'ይመልከቱ',
+ toggleColumns: 'አምዶችን ቀያይር',
+ noResult: 'ምንም አልተገኘም',
},
breadcrumbs: {
diff --git a/apps/backoffice/src/app/i18n/locales/en.ts b/apps/backoffice/src/app/i18n/locales/en.ts
index 5bb567862..d928b0e42 100644
--- a/apps/backoffice/src/app/i18n/locales/en.ts
+++ b/apps/backoffice/src/app/i18n/locales/en.ts
@@ -96,6 +96,10 @@ export const en = {
collapse: 'Collapse',
expand: 'Expand',
toggleTheme: 'Toggle light / dark mode',
+ refresh: 'Refresh',
+ view: 'View',
+ toggleColumns: 'Toggle columns',
+ noResult: 'No results',
},
breadcrumbs: {
diff --git a/apps/portal/src/app/features/payment/api/client.ts b/apps/portal/src/app/features/payment/api/client.ts
deleted file mode 100644
index 0e09c33cd..000000000
--- a/apps/portal/src/app/features/payment/api/client.ts
+++ /dev/null
@@ -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 }).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 {
- 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;
- 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(path: string, init?: RequestInit): Promise {
- 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;
-}
diff --git a/apps/portal/src/app/features/payment/api/payments.test.ts b/apps/portal/src/app/features/payment/api/payments.test.ts
deleted file mode 100644
index 5fb1eb24e..000000000
--- a/apps/portal/src/app/features/payment/api/payments.test.ts
+++ /dev/null
@@ -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();
- (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([]);
- });
-});
diff --git a/apps/portal/src/app/features/payment/api/payments.ts b/apps/portal/src/app/features/payment/api/payments.ts
deleted file mode 100644
index 789bf3624..000000000
--- a/apps/portal/src/app/features/payment/api/payments.ts
+++ /dev/null
@@ -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 {
- return paymentFetch('/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 {
- return paymentFetch(`/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 {
- return paymentFetch(`/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 {
- 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();
-}
diff --git a/apps/portal/src/app/features/payment/components/PaymentModal.tsx b/apps/portal/src/app/features/payment/components/PaymentModal.tsx
deleted file mode 100644
index e53c490cb..000000000
--- a/apps/portal/src/app/features/payment/components/PaymentModal.tsx
+++ /dev/null
@@ -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 (
-
-
-
- Amount
- {formatMinor(amountMinor, currency)}
-
-
- Provider
- {provider}
-
-
- {phase === PAYMENT_PHASE.Idle && (
-
- )}
-
- {phase === PAYMENT_PHASE.Creating && (
-
-
- Creating your payment…
-
- )}
-
- {phase === PAYMENT_PHASE.Opening && (
-
-
- Opening Telebirr…
-
- )}
-
- {phase === PAYMENT_PHASE.Blocked && redirectUrl && (
- } title="Popup blocked">
-
- Your browser blocked the payment window. Open it manually to continue.
-
-
-
- )}
-
- {phase === PAYMENT_PHASE.Waiting && (
-
-
-
-
- Waiting for confirmation from Telebirr… {intent ? `(${intent.status})` : null}
-
-
- {unhandledActionType && (
-
- The payment service returned an unhandled action ("{unhandledActionType}") — still
- checking for a result.
-
- )}
- {redirectUrl && (
- window.open(redirectUrl, '_blank', 'noopener')}>
- Reopen Telebirr
-
- )}
- {isSlow && (
-
-
- This is taking longer than usual. It may still complete.
-
-
-
-
-
-
- )}
-
- )}
-
- {phase === PAYMENT_PHASE.Success && (
- } title="Payment confirmed">
- {intent ? `Reference: ${intent.id}` : null}
-
- )}
-
- {phase === PAYMENT_PHASE.Failed && (
-
-
-
-
- )}
-
-
- );
-}
diff --git a/apps/portal/src/app/features/payment/constants.ts b/apps/portal/src/app/features/payment/constants.ts
deleted file mode 100644
index 999f186f3..000000000
--- a/apps/portal/src/app/features/payment/constants.ts
+++ /dev/null
@@ -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 };
-}
diff --git a/apps/portal/src/app/features/payment/hooks/usePaymentFlow.ts b/apps/portal/src/app/features/payment/hooks/usePaymentFlow.ts
deleted file mode 100644
index 39ec69e25..000000000
--- a/apps/portal/src/app/features/payment/hooks/usePaymentFlow.ts
+++ /dev/null
@@ -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(PAYMENT_PHASE.Idle);
- const [intentId, setIntentId] = useState(null);
- const [error, setError] = useState(null);
- const [redirectUrl, setRedirectUrl] = useState(null);
- const [startedAt, setStartedAt] = useState(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(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,
- };
-}
diff --git a/apps/portal/src/app/features/payment/pages/PaymentsPage.tsx b/apps/portal/src/app/features/payment/pages/PaymentsPage.tsx
deleted file mode 100644
index 18e43e438..000000000
--- a/apps/portal/src/app/features/payment/pages/PaymentsPage.tsx
+++ /dev/null
@@ -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([]);
- 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 (
-
-
- Payment Status
-
- {reconciled && (
-
- {isSuccess ? (
- } title="Payment successful">
-
- {totalAmount && (
-
- Amount: {totalAmount} {transCurrency ?? ''}
-
- )}
- {paymentOrderId && Order: {paymentOrderId}}
-
-
- ) : (
- } title="Payment status">
-
-
- Latest status:
-
- {record?.status ?? tradeStatus ?? 'UNKNOWN'}
-
-
- {totalAmount && (
-
- Amount: {totalAmount} {transCurrency ?? ''}
-
- )}
-
-
- )}
-
- )}
-
-
- Payment History
- {history.length === 0 ? (
- No payments recorded on this device yet.
- ) : (
-
-
-
- Title
- Order Ref
- Amount
- Status
- Date
-
-
-
- {[...history].reverse().map((r) => (
-
- {r.title}
- {r.orderRef}
- {formatMinor(r.amountMinor, r.currency)}
-
- {r.status}
-
- {new Date(r.createdAt).toLocaleString()}
-
- ))}
-
-
- )}
-
-
-
- );
-}
diff --git a/apps/portal/src/app/features/payment/types/payment.ts b/apps/portal/src/app/features/payment/types/payment.ts
deleted file mode 100644
index 9895732c2..000000000
--- a/apps/portal/src/app/features/payment/types/payment.ts
+++ /dev/null
@@ -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;
-}
diff --git a/apps/portal/src/app/features/payment/utils/money.ts b/apps/portal/src/app/features/payment/utils/money.ts
deleted file mode 100644
index 97bbf338c..000000000
--- a/apps/portal/src/app/features/payment/utils/money.ts
+++ /dev/null
@@ -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,
- })}`;
-}
diff --git a/libs/ui/src/lib/data/AdvancedTable.tsx b/libs/ui/src/lib/data/AdvancedTable.tsx
index 004f55451..4fcae4570 100644
--- a/libs/ui/src/lib/data/AdvancedTable.tsx
+++ b/libs/ui/src/lib/data/AdvancedTable.tsx
@@ -10,7 +10,6 @@ import {
Loader,
Center,
Paper,
- Badge,
} from "@mantine/core";
import { IconRefresh, IconAdjustmentsHorizontal , IconInbox, } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
@@ -24,6 +23,8 @@ export interface AdvancedColumn {
align?: "left" | "center" | "right";
/** Whether column starts visible. Default true. */
enabled?: boolean;
+ /** Label for the View menu; falls back to `header` when it is a plain string. */
+ label?: string;
}
interface AdvancedTableProps {
@@ -90,23 +91,23 @@ export function AdvancedTable({
{""}
+ {refresh && (
+ }
+ onClick={refresh}
+ loading={isLoading}
+ >
+ {t("common.refresh", "Refresh")}
+
+ )}
diff --git a/libs/ui/src/lib/data/useServerTable.ts b/libs/ui/src/lib/data/useServerTable.ts
index 910842056..2ea428bbb 100644
--- a/libs/ui/src/lib/data/useServerTable.ts
+++ b/libs/ui/src/lib/data/useServerTable.ts
@@ -17,6 +17,22 @@ export function useServerTable({ pageSize = 10 }: UseServerTableOptions = {}) {
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(
+ (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 {
pageIndex,
setPageIndex,
@@ -25,5 +41,6 @@ export function useServerTable({ pageSize = 10 }: UseServerTableOptions = {}) {
pageSize,
skip: pageIndex * pageSize,
take: pageSize,
+ paginate,
};
}