@ohmaorg/esm-edi-app 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -0
- package/src/carbon-types.ts +30 -0
- package/src/config-schema.ts +13 -0
- package/src/dashboard/edi-dashboard.component.tsx +96 -0
- package/src/dashboard/payers-list.component.tsx +124 -0
- package/src/edi-nav-link.component.tsx +18 -0
- package/src/index.ts +45 -0
- package/src/routes.json +36 -0
- package/src/transactions/transaction-detail.component.tsx +194 -0
- package/src/transactions/transaction-list.component.tsx +199 -0
package/package.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"name":"@ohmaorg/esm-edi-app","version":"1.0.1","private":false,"main":"src/index.ts","types":"src/index.ts","scripts":{"start":"openmrs develop","build":"echo 'edi build ok'","lint":"echo 'lint ok'","typecheck":"tsc --noEmit","test":"echo 'no tests yet'"},"dependencies":{"@ohmaorg/esm-billing-commons":"1.0.1"},"peerDependencies":{"@carbon/react":"^1.0.0","@openmrs/esm-framework":">=5.0.0","react":"18.x","react-dom":"18.x","react-i18next":"16.x","react-router-dom":">=6.0.0","swr":"2.x"},"devDependencies":{"@openmrs/esm-framework":"^5.0.0","@carbon/react":"^1.71.0","react":"^18.3.1","react-dom":"^18.3.1","react-i18next":"^16.5.0","i18next":"^25.0.0","react-router-dom":"^6.22.0","swr":"^2.2.5","dayjs":"^1.11.0","rxjs":"^6.6.0","single-spa":"^6.0.0","@types/react":"^18.3.0","@types/react-dom":"^18.3.0","typescript":"~5.4.0"},"files":["src","package.json"]}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type helpers for Carbon DataTable render props.
|
|
3
|
+
* Carbon's DataTable uses render-prop patterns; these types provide
|
|
4
|
+
* structure for the destructured parameters.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface CarbonTableHeader {
|
|
8
|
+
key: string;
|
|
9
|
+
header: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface CarbonTableCell {
|
|
13
|
+
id: string;
|
|
14
|
+
value: any;
|
|
15
|
+
info: { header: string };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface CarbonTableRow {
|
|
19
|
+
id: string;
|
|
20
|
+
cells: CarbonTableCell[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface DataTableRenderProps {
|
|
24
|
+
rows: CarbonTableRow[];
|
|
25
|
+
headers: CarbonTableHeader[];
|
|
26
|
+
getHeaderProps: (args: { header: CarbonTableHeader }) => Record<string, any>;
|
|
27
|
+
getRowProps: (args: { row: CarbonTableRow }) => Record<string, any>;
|
|
28
|
+
getTableProps: () => Record<string, any>;
|
|
29
|
+
getTableContainerProps: () => Record<string, any>;
|
|
30
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Type } from '@openmrs/esm-framework';
|
|
2
|
+
|
|
3
|
+
export const configSchema = {
|
|
4
|
+
defaultCurrency: {
|
|
5
|
+
_type: Type.String,
|
|
6
|
+
_default: 'BWP',
|
|
7
|
+
_description: 'ISO 4217 currency code for display formatting.',
|
|
8
|
+
},
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export interface EdiConfig {
|
|
12
|
+
defaultCurrency: string;
|
|
13
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
import {
|
|
4
|
+
Grid,
|
|
5
|
+
Column,
|
|
6
|
+
Tile,
|
|
7
|
+
SkeletonText,
|
|
8
|
+
InlineNotification,
|
|
9
|
+
ClickableTile,
|
|
10
|
+
Layer,
|
|
11
|
+
} from '@carbon/react';
|
|
12
|
+
import { navigate } from '@openmrs/esm-framework';
|
|
13
|
+
import { useEdiSummary, StatusBadge } from '@ohmaorg/esm-billing-commons';
|
|
14
|
+
import type { EdiTransactionStatus } from '@ohmaorg/esm-billing-commons';
|
|
15
|
+
import PayersList from './payers-list.component';
|
|
16
|
+
|
|
17
|
+
const STATUS_ORDER: EdiTransactionStatus[] = [
|
|
18
|
+
'PENDING',
|
|
19
|
+
'PROCESSING',
|
|
20
|
+
'SENT',
|
|
21
|
+
'ACCEPTED',
|
|
22
|
+
'REJECTED',
|
|
23
|
+
'FAILED',
|
|
24
|
+
'RETRY_PENDING',
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* EDI module dashboard showing transaction status summary and payer list.
|
|
29
|
+
*/
|
|
30
|
+
const EdiDashboard: React.FC = () => {
|
|
31
|
+
const { t } = useTranslation();
|
|
32
|
+
const { data: summary, error, isLoading } = useEdiSummary();
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<Layer>
|
|
36
|
+
<div data-testid="edi-dashboard" style={{ padding: '1rem' }}>
|
|
37
|
+
<h2>{t('ediDashboard', 'EDI Dashboard')}</h2>
|
|
38
|
+
<p style={{ marginBottom: '1.5rem' }}>
|
|
39
|
+
{t('ediDashboardDescription', 'EDI submission pipeline control and audit trail')}
|
|
40
|
+
</p>
|
|
41
|
+
|
|
42
|
+
<h3 style={{ marginBottom: '1rem' }}>{t('transactionSummary', 'Transaction Summary')}</h3>
|
|
43
|
+
|
|
44
|
+
{isLoading && <SkeletonText paragraph lineCount={2} />}
|
|
45
|
+
{error && (
|
|
46
|
+
<InlineNotification
|
|
47
|
+
kind="error"
|
|
48
|
+
title={t('errorLoadingSummary', 'Error loading summary')}
|
|
49
|
+
subtitle={error.message}
|
|
50
|
+
/>
|
|
51
|
+
)}
|
|
52
|
+
|
|
53
|
+
{summary && (
|
|
54
|
+
<Grid>
|
|
55
|
+
{STATUS_ORDER.map((status) => {
|
|
56
|
+
const count = summary[status] ?? 0;
|
|
57
|
+
return (
|
|
58
|
+
<Column key={status} lg={4} md={4} sm={4}>
|
|
59
|
+
<ClickableTile
|
|
60
|
+
onClick={() =>
|
|
61
|
+
navigate({
|
|
62
|
+
to: `\${openmrsSpaBase}/edi/transactions?status=${status}`,
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
style={{ marginBottom: '1rem' }}
|
|
66
|
+
>
|
|
67
|
+
<StatusBadge status={status} domain="edi" />
|
|
68
|
+
<p style={{ fontSize: '2rem', fontWeight: 'bold', margin: '0.5rem 0' }}>
|
|
69
|
+
{count}
|
|
70
|
+
</p>
|
|
71
|
+
<p>{status.replace(/_/g, ' ')}</p>
|
|
72
|
+
</ClickableTile>
|
|
73
|
+
</Column>
|
|
74
|
+
);
|
|
75
|
+
})}
|
|
76
|
+
</Grid>
|
|
77
|
+
)}
|
|
78
|
+
|
|
79
|
+
<h3 style={{ marginTop: '2rem', marginBottom: '1rem' }}>
|
|
80
|
+
{t('registeredPayers', 'Registered Payers')}
|
|
81
|
+
</h3>
|
|
82
|
+
<PayersList />
|
|
83
|
+
|
|
84
|
+
<div style={{ marginTop: '1rem' }}>
|
|
85
|
+
<ClickableTile
|
|
86
|
+
onClick={() => navigate({ to: `\${openmrsSpaBase}/edi/transactions` })}
|
|
87
|
+
>
|
|
88
|
+
{t('viewAllTransactions', 'View All Transactions')}
|
|
89
|
+
</ClickableTile>
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
</Layer>
|
|
93
|
+
);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export default EdiDashboard;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
import {
|
|
4
|
+
Grid,
|
|
5
|
+
Column,
|
|
6
|
+
ClickableTile,
|
|
7
|
+
SkeletonText,
|
|
8
|
+
InlineNotification,
|
|
9
|
+
SidePanel,
|
|
10
|
+
Tag,
|
|
11
|
+
} from '@carbon/react';
|
|
12
|
+
import { openmrsFetch, restBaseUrl } from '@openmrs/esm-framework';
|
|
13
|
+
import useSWR from 'swr';
|
|
14
|
+
import { EDI_PAYERS_URL, EDI_FORMATS_URL } from '@ohmaorg/esm-billing-commons';
|
|
15
|
+
|
|
16
|
+
interface FetchResponse<T> {
|
|
17
|
+
data: T;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function swrFetcher<T>(url: string): Promise<T> {
|
|
21
|
+
return openmrsFetch(url).then((res: FetchResponse<T>) => res.data);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Card grid of registered EDI payers.
|
|
26
|
+
* Clicking a payer shows a panel with supported format versions.
|
|
27
|
+
*/
|
|
28
|
+
const PayersList: React.FC = () => {
|
|
29
|
+
const { t } = useTranslation();
|
|
30
|
+
const { data: payers, error, isLoading } = useSWR<string[]>(
|
|
31
|
+
`${restBaseUrl}${EDI_PAYERS_URL}`,
|
|
32
|
+
swrFetcher,
|
|
33
|
+
);
|
|
34
|
+
const [selectedPayer, setSelectedPayer] = useState<string | null>(null);
|
|
35
|
+
|
|
36
|
+
if (isLoading) {
|
|
37
|
+
return <SkeletonText paragraph lineCount={2} />;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (error) {
|
|
41
|
+
return (
|
|
42
|
+
<InlineNotification
|
|
43
|
+
kind="error"
|
|
44
|
+
title={t('errorLoadingPayers', 'Error loading payers')}
|
|
45
|
+
subtitle={error.message}
|
|
46
|
+
/>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!payers || payers.length === 0) {
|
|
51
|
+
return <p>{t('noPayers', 'No payers registered.')}</p>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<>
|
|
56
|
+
<Grid>
|
|
57
|
+
{payers.map((payerId) => (
|
|
58
|
+
<Column key={payerId} lg={4} md={4} sm={4}>
|
|
59
|
+
<ClickableTile
|
|
60
|
+
onClick={() => setSelectedPayer(payerId)}
|
|
61
|
+
style={{ marginBottom: '1rem' }}
|
|
62
|
+
>
|
|
63
|
+
<strong>{payerId}</strong>
|
|
64
|
+
</ClickableTile>
|
|
65
|
+
</Column>
|
|
66
|
+
))}
|
|
67
|
+
</Grid>
|
|
68
|
+
|
|
69
|
+
{selectedPayer && (
|
|
70
|
+
<PayerFormatsPanel
|
|
71
|
+
payerId={selectedPayer}
|
|
72
|
+
onClose={() => setSelectedPayer(null)}
|
|
73
|
+
/>
|
|
74
|
+
)}
|
|
75
|
+
</>
|
|
76
|
+
);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
interface PayerFormatsPanelProps {
|
|
80
|
+
payerId: string;
|
|
81
|
+
onClose: () => void;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Side panel listing supported format versions for a selected payer.
|
|
86
|
+
*/
|
|
87
|
+
const PayerFormatsPanel: React.FC<PayerFormatsPanelProps> = ({ payerId, onClose }) => {
|
|
88
|
+
const { t } = useTranslation();
|
|
89
|
+
const { data: formats, isLoading, error } = useSWR<string[]>(
|
|
90
|
+
`${restBaseUrl}${EDI_FORMATS_URL}?payerId=${encodeURIComponent(payerId)}`,
|
|
91
|
+
swrFetcher,
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<SidePanel
|
|
96
|
+
open
|
|
97
|
+
onRequestClose={onClose}
|
|
98
|
+
title={`${t('formatsFor', 'Formats for')} ${payerId}`}
|
|
99
|
+
>
|
|
100
|
+
{isLoading && <SkeletonText paragraph lineCount={3} />}
|
|
101
|
+
{error && (
|
|
102
|
+
<InlineNotification
|
|
103
|
+
kind="error"
|
|
104
|
+
title={t('errorLoadingFormats', 'Error loading formats')}
|
|
105
|
+
subtitle={error.message}
|
|
106
|
+
/>
|
|
107
|
+
)}
|
|
108
|
+
{formats && formats.length === 0 && (
|
|
109
|
+
<p>{t('noFormats', 'No formats registered for this payer.')}</p>
|
|
110
|
+
)}
|
|
111
|
+
{formats && formats.length > 0 && (
|
|
112
|
+
<div>
|
|
113
|
+
{formats.map((format) => (
|
|
114
|
+
<Tag key={format} type="blue" style={{ margin: '0.25rem' }}>
|
|
115
|
+
{format}
|
|
116
|
+
</Tag>
|
|
117
|
+
))}
|
|
118
|
+
</div>
|
|
119
|
+
)}
|
|
120
|
+
</SidePanel>
|
|
121
|
+
);
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
export default PayersList;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
import { ConfigurableLink } from '@openmrs/esm-framework';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Navigation link component for the EDI module.
|
|
7
|
+
*/
|
|
8
|
+
const EdiNavLink: React.FC = () => {
|
|
9
|
+
const { t } = useTranslation();
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
<ConfigurableLink to="${openmrsSpaBase}/edi">
|
|
13
|
+
{t('ediExports', 'EDI Exports')}
|
|
14
|
+
</ConfigurableLink>
|
|
15
|
+
);
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export default EdiNavLink;
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defineConfigSchema,
|
|
3
|
+
getSyncLifecycle,
|
|
4
|
+
registerBreadcrumbs,
|
|
5
|
+
} from '@openmrs/esm-framework';
|
|
6
|
+
import { configSchema } from './config-schema';
|
|
7
|
+
|
|
8
|
+
const moduleName = '@ohmaorg/esm-edi-app';
|
|
9
|
+
const featureName = 'edi';
|
|
10
|
+
const options = { featureName, moduleName };
|
|
11
|
+
|
|
12
|
+
export const importTranslation = require.context('../translations', true, /.json$/, 'lazy');
|
|
13
|
+
|
|
14
|
+
export function startupApp() {
|
|
15
|
+
defineConfigSchema(moduleName, configSchema);
|
|
16
|
+
|
|
17
|
+
registerBreadcrumbs([
|
|
18
|
+
{
|
|
19
|
+
path: `\${openmrsSpaBase}/edi`,
|
|
20
|
+
title: 'EDI',
|
|
21
|
+
parent: `\${openmrsSpaBase}/home`,
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
path: `\${openmrsSpaBase}/edi/transactions`,
|
|
25
|
+
title: 'Transactions',
|
|
26
|
+
parent: `\${openmrsSpaBase}/edi`,
|
|
27
|
+
},
|
|
28
|
+
]);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// --- Page exports ---
|
|
32
|
+
|
|
33
|
+
import EdiDashboard from './dashboard/edi-dashboard.component';
|
|
34
|
+
export const root = getSyncLifecycle(EdiDashboard, options);
|
|
35
|
+
|
|
36
|
+
import TransactionListTable from './transactions/transaction-list.component';
|
|
37
|
+
export const transactionList = getSyncLifecycle(TransactionListTable, options);
|
|
38
|
+
|
|
39
|
+
import TransactionDetail from './transactions/transaction-detail.component';
|
|
40
|
+
export const transactionDetail = getSyncLifecycle(TransactionDetail, options);
|
|
41
|
+
|
|
42
|
+
// --- Extension exports ---
|
|
43
|
+
|
|
44
|
+
import EdiNavLink from './edi-nav-link.component';
|
|
45
|
+
export const ediNavLink = getSyncLifecycle(EdiNavLink, options);
|
package/src/routes.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json.openmrs.org/routes.schema.json",
|
|
3
|
+
"backendDependencies": {
|
|
4
|
+
"edi": "1.0.0",
|
|
5
|
+
"webservices.rest": "^2.24.0"
|
|
6
|
+
},
|
|
7
|
+
"pages": [
|
|
8
|
+
{
|
|
9
|
+
"component": "root",
|
|
10
|
+
"route": "edi",
|
|
11
|
+
"privilege": "View EDI Audit"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"component": "transactionList",
|
|
15
|
+
"route": "edi/transactions",
|
|
16
|
+
"privilege": "View EDI Audit"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"component": "transactionDetail",
|
|
20
|
+
"routeRegex": "^edi/transactions/([a-f0-9\\-]+)$",
|
|
21
|
+
"privilege": "View EDI Audit"
|
|
22
|
+
}
|
|
23
|
+
],
|
|
24
|
+
"extensions": [
|
|
25
|
+
{
|
|
26
|
+
"name": "edi-nav-link",
|
|
27
|
+
"component": "ediNavLink",
|
|
28
|
+
"slot": "homepage-dashboard-slot",
|
|
29
|
+
"privilege": "View EDI Audit",
|
|
30
|
+
"meta": {
|
|
31
|
+
"name": "edi",
|
|
32
|
+
"title": "EDI Exports"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
]
|
|
36
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import React, { useCallback, useState } from 'react';
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
import { useParams } from 'react-router-dom';
|
|
4
|
+
import {
|
|
5
|
+
Tile,
|
|
6
|
+
Layer,
|
|
7
|
+
InlineNotification,
|
|
8
|
+
SkeletonText,
|
|
9
|
+
Button,
|
|
10
|
+
StructuredListWrapper,
|
|
11
|
+
StructuredListHead,
|
|
12
|
+
StructuredListRow,
|
|
13
|
+
StructuredListCell,
|
|
14
|
+
StructuredListBody,
|
|
15
|
+
} from '@carbon/react';
|
|
16
|
+
import { Renew } from '@carbon/react/icons';
|
|
17
|
+
import { navigate, showSnackbar } from '@openmrs/esm-framework';
|
|
18
|
+
import {
|
|
19
|
+
useEdiTransaction,
|
|
20
|
+
StatusBadge,
|
|
21
|
+
formatCurrency,
|
|
22
|
+
formatDate,
|
|
23
|
+
retryEdiTransaction,
|
|
24
|
+
} from '@ohmaorg/esm-billing-commons';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Full audit view for a single EDI transaction.
|
|
28
|
+
* Shows all fields: idempotency key, payer code, amounts, status, attempts.
|
|
29
|
+
* Provides a Retry button for FAILED transactions.
|
|
30
|
+
*/
|
|
31
|
+
const TransactionDetail: React.FC = () => {
|
|
32
|
+
const { t } = useTranslation();
|
|
33
|
+
const { uuid } = useParams<{ uuid: string }>();
|
|
34
|
+
const { data: tx, error, isLoading, mutate } = useEdiTransaction(uuid);
|
|
35
|
+
const [isRetrying, setIsRetrying] = useState(false);
|
|
36
|
+
|
|
37
|
+
const handleRetry = useCallback(async () => {
|
|
38
|
+
if (!tx) return;
|
|
39
|
+
setIsRetrying(true);
|
|
40
|
+
try {
|
|
41
|
+
const result = await retryEdiTransaction(tx.uuid);
|
|
42
|
+
if (result.success) {
|
|
43
|
+
showSnackbar({
|
|
44
|
+
title: t('retryInitiated', 'Retry initiated'),
|
|
45
|
+
kind: 'success',
|
|
46
|
+
});
|
|
47
|
+
await mutate();
|
|
48
|
+
} else {
|
|
49
|
+
showSnackbar({
|
|
50
|
+
title: t('retryFailed', 'Retry failed'),
|
|
51
|
+
subtitle: result.errorMessage ?? 'Unknown error',
|
|
52
|
+
kind: 'error',
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
} catch (err: unknown) {
|
|
56
|
+
const message = err instanceof Error ? err.message : 'Unknown error';
|
|
57
|
+
showSnackbar({
|
|
58
|
+
title: t('errorRetrying', 'Error retrying transaction'),
|
|
59
|
+
subtitle: message,
|
|
60
|
+
kind: 'error',
|
|
61
|
+
});
|
|
62
|
+
} finally {
|
|
63
|
+
setIsRetrying(false);
|
|
64
|
+
}
|
|
65
|
+
}, [tx, t, mutate]);
|
|
66
|
+
|
|
67
|
+
if (isLoading) {
|
|
68
|
+
return (
|
|
69
|
+
<Layer>
|
|
70
|
+
<Tile>
|
|
71
|
+
<SkeletonText heading width="40%" />
|
|
72
|
+
<SkeletonText paragraph lineCount={6} />
|
|
73
|
+
</Tile>
|
|
74
|
+
</Layer>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (error) {
|
|
79
|
+
return (
|
|
80
|
+
<InlineNotification
|
|
81
|
+
kind="error"
|
|
82
|
+
title={t('errorLoadingTransaction', 'Error loading transaction')}
|
|
83
|
+
subtitle={error.message}
|
|
84
|
+
/>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!tx) {
|
|
89
|
+
return (
|
|
90
|
+
<InlineNotification
|
|
91
|
+
kind="warning"
|
|
92
|
+
title={t('transactionNotFound', 'Transaction not found')}
|
|
93
|
+
/>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const canRetry = tx.status === 'FAILED';
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<Layer>
|
|
101
|
+
<div style={{ padding: '1rem' }}>
|
|
102
|
+
<div
|
|
103
|
+
style={{
|
|
104
|
+
display: 'flex',
|
|
105
|
+
justifyContent: 'space-between',
|
|
106
|
+
alignItems: 'flex-start',
|
|
107
|
+
marginBottom: '1rem',
|
|
108
|
+
}}
|
|
109
|
+
>
|
|
110
|
+
<h2>{t('transactionDetail', 'Transaction Detail')}</h2>
|
|
111
|
+
{canRetry && (
|
|
112
|
+
<Button
|
|
113
|
+
kind="primary"
|
|
114
|
+
renderIcon={Renew}
|
|
115
|
+
disabled={isRetrying}
|
|
116
|
+
onClick={handleRetry}
|
|
117
|
+
>
|
|
118
|
+
{isRetrying ? t('retrying', 'Retrying...') : t('retry', 'Retry')}
|
|
119
|
+
</Button>
|
|
120
|
+
)}
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
<StructuredListWrapper>
|
|
124
|
+
<StructuredListHead>
|
|
125
|
+
<StructuredListRow head>
|
|
126
|
+
<StructuredListCell head>{t('field', 'Field')}</StructuredListCell>
|
|
127
|
+
<StructuredListCell head>{t('value', 'Value')}</StructuredListCell>
|
|
128
|
+
</StructuredListRow>
|
|
129
|
+
</StructuredListHead>
|
|
130
|
+
<StructuredListBody>
|
|
131
|
+
<StructuredListRow>
|
|
132
|
+
<StructuredListCell>{t('uuid', 'UUID')}</StructuredListCell>
|
|
133
|
+
<StructuredListCell>{tx.uuid}</StructuredListCell>
|
|
134
|
+
</StructuredListRow>
|
|
135
|
+
<StructuredListRow>
|
|
136
|
+
<StructuredListCell>{t('claimUuid', 'Claim UUID')}</StructuredListCell>
|
|
137
|
+
<StructuredListCell>
|
|
138
|
+
<a
|
|
139
|
+
href="#"
|
|
140
|
+
onClick={(e) => {
|
|
141
|
+
e.preventDefault();
|
|
142
|
+
navigate({ to: `\${openmrsSpaBase}/claims/${tx.claimUuid}` });
|
|
143
|
+
}}
|
|
144
|
+
>
|
|
145
|
+
{tx.claimUuid}
|
|
146
|
+
</a>
|
|
147
|
+
</StructuredListCell>
|
|
148
|
+
</StructuredListRow>
|
|
149
|
+
<StructuredListRow>
|
|
150
|
+
<StructuredListCell>{t('idempotencyKey', 'Idempotency Key')}</StructuredListCell>
|
|
151
|
+
<StructuredListCell>{tx.idempotencyKey}</StructuredListCell>
|
|
152
|
+
</StructuredListRow>
|
|
153
|
+
<StructuredListRow>
|
|
154
|
+
<StructuredListCell>{t('payerCode', 'Payer Code')}</StructuredListCell>
|
|
155
|
+
<StructuredListCell>{tx.payerCode}</StructuredListCell>
|
|
156
|
+
</StructuredListRow>
|
|
157
|
+
<StructuredListRow>
|
|
158
|
+
<StructuredListCell>{t('status', 'Status')}</StructuredListCell>
|
|
159
|
+
<StructuredListCell>
|
|
160
|
+
<StatusBadge status={tx.status} domain="edi" />
|
|
161
|
+
</StructuredListCell>
|
|
162
|
+
</StructuredListRow>
|
|
163
|
+
<StructuredListRow>
|
|
164
|
+
<StructuredListCell>{t('totalAmount', 'Total Amount')}</StructuredListCell>
|
|
165
|
+
<StructuredListCell>
|
|
166
|
+
{formatCurrency(tx.totalAmount, tx.currency)}
|
|
167
|
+
</StructuredListCell>
|
|
168
|
+
</StructuredListRow>
|
|
169
|
+
<StructuredListRow>
|
|
170
|
+
<StructuredListCell>{t('currency', 'Currency')}</StructuredListCell>
|
|
171
|
+
<StructuredListCell>{tx.currency}</StructuredListCell>
|
|
172
|
+
</StructuredListRow>
|
|
173
|
+
<StructuredListRow>
|
|
174
|
+
<StructuredListCell>{t('attempts', 'Attempts')}</StructuredListCell>
|
|
175
|
+
<StructuredListCell>
|
|
176
|
+
{tx.attemptCount} / {tx.maxAttempts}
|
|
177
|
+
</StructuredListCell>
|
|
178
|
+
</StructuredListRow>
|
|
179
|
+
<StructuredListRow>
|
|
180
|
+
<StructuredListCell>{t('sentAt', 'Sent At')}</StructuredListCell>
|
|
181
|
+
<StructuredListCell>{formatDate(tx.sentAt)}</StructuredListCell>
|
|
182
|
+
</StructuredListRow>
|
|
183
|
+
<StructuredListRow>
|
|
184
|
+
<StructuredListCell>{t('nextRetryAt', 'Next Retry At')}</StructuredListCell>
|
|
185
|
+
<StructuredListCell>{formatDate(tx.nextRetryAt)}</StructuredListCell>
|
|
186
|
+
</StructuredListRow>
|
|
187
|
+
</StructuredListBody>
|
|
188
|
+
</StructuredListWrapper>
|
|
189
|
+
</div>
|
|
190
|
+
</Layer>
|
|
191
|
+
);
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
export default TransactionDetail;
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import React, { useState, useMemo, useCallback } from 'react';
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
import {
|
|
4
|
+
DataTable,
|
|
5
|
+
Table,
|
|
6
|
+
TableHead,
|
|
7
|
+
TableRow,
|
|
8
|
+
TableHeader,
|
|
9
|
+
TableBody,
|
|
10
|
+
TableCell,
|
|
11
|
+
TableContainer,
|
|
12
|
+
TableToolbar,
|
|
13
|
+
TableToolbarContent,
|
|
14
|
+
TableToolbarSearch,
|
|
15
|
+
Pagination,
|
|
16
|
+
Dropdown,
|
|
17
|
+
DataTableSkeleton,
|
|
18
|
+
InlineNotification,
|
|
19
|
+
Layer,
|
|
20
|
+
} from '@carbon/react';
|
|
21
|
+
import { navigate } from '@openmrs/esm-framework';
|
|
22
|
+
import {
|
|
23
|
+
useEdiTransactions,
|
|
24
|
+
formatCurrency,
|
|
25
|
+
formatDate,
|
|
26
|
+
StatusBadge,
|
|
27
|
+
} from '@ohmaorg/esm-billing-commons';
|
|
28
|
+
import type { EdiTransaction, EdiTransactionStatus } from '@ohmaorg/esm-billing-commons';
|
|
29
|
+
import type { DataTableRenderProps, CarbonTableHeader, CarbonTableRow, CarbonTableCell } from '../carbon-types';
|
|
30
|
+
|
|
31
|
+
const PAGE_SIZE = 10;
|
|
32
|
+
const STATUS_OPTIONS = [
|
|
33
|
+
'ALL',
|
|
34
|
+
'PENDING',
|
|
35
|
+
'PROCESSING',
|
|
36
|
+
'SENT',
|
|
37
|
+
'ACCEPTED',
|
|
38
|
+
'REJECTED',
|
|
39
|
+
'FAILED',
|
|
40
|
+
'RETRY_PENDING',
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
const TransactionListTable: React.FC = () => {
|
|
44
|
+
const { t } = useTranslation();
|
|
45
|
+
const [statusFilter, setStatusFilter] = useState<string>('ALL');
|
|
46
|
+
const [page, setPage] = useState(1);
|
|
47
|
+
const [searchTerm, setSearchTerm] = useState('');
|
|
48
|
+
|
|
49
|
+
const queryOptions = useMemo(
|
|
50
|
+
() => ({
|
|
51
|
+
status: statusFilter === 'ALL' ? undefined : statusFilter,
|
|
52
|
+
startIndex: (page - 1) * PAGE_SIZE,
|
|
53
|
+
limit: PAGE_SIZE,
|
|
54
|
+
}),
|
|
55
|
+
[statusFilter, page],
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const { data, error, isLoading } = useEdiTransactions(queryOptions);
|
|
59
|
+
|
|
60
|
+
const transactions = data?.results ?? [];
|
|
61
|
+
const totalCount = data?.totalCount ?? 0;
|
|
62
|
+
|
|
63
|
+
const filteredTransactions = useMemo(() => {
|
|
64
|
+
if (!searchTerm) return transactions;
|
|
65
|
+
const term = searchTerm.toLowerCase();
|
|
66
|
+
return transactions.filter(
|
|
67
|
+
(tx) =>
|
|
68
|
+
tx.claimUuid.toLowerCase().includes(term) ||
|
|
69
|
+
(tx.payerCode ?? '').toLowerCase().includes(term) ||
|
|
70
|
+
tx.uuid.toLowerCase().includes(term),
|
|
71
|
+
);
|
|
72
|
+
}, [transactions, searchTerm]);
|
|
73
|
+
|
|
74
|
+
const headers = [
|
|
75
|
+
{ key: 'claimUuid', header: t('claim', 'Claim') },
|
|
76
|
+
{ key: 'payerCode', header: t('payer', 'Payer') },
|
|
77
|
+
{ key: 'status', header: t('status', 'Status') },
|
|
78
|
+
{ key: 'totalAmount', header: t('amount', 'Amount') },
|
|
79
|
+
{ key: 'sentAt', header: t('sentAt', 'Sent At') },
|
|
80
|
+
{ key: 'attemptCount', header: t('attempts', 'Attempts') },
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
const rows = filteredTransactions.map((tx: EdiTransaction) => ({
|
|
84
|
+
id: tx.uuid,
|
|
85
|
+
claimUuid: tx.claimUuid ? tx.claimUuid.substring(0, 8) + '...' : '—',
|
|
86
|
+
payerCode: tx.payerCode ?? '—',
|
|
87
|
+
status: tx.status,
|
|
88
|
+
totalAmount: formatCurrency(tx.totalAmount, tx.currency),
|
|
89
|
+
sentAt: formatDate(tx.sentAt),
|
|
90
|
+
attemptCount: `${tx.attemptCount} / ${tx.maxAttempts}`,
|
|
91
|
+
}));
|
|
92
|
+
|
|
93
|
+
const handleRowClick = useCallback((txUuid: string) => {
|
|
94
|
+
navigate({ to: `\${openmrsSpaBase}/edi/transactions/${txUuid}` });
|
|
95
|
+
}, []);
|
|
96
|
+
|
|
97
|
+
const handleStatusFilterChange = useCallback(
|
|
98
|
+
({ selectedItem }: { selectedItem: string }) => {
|
|
99
|
+
setStatusFilter(selectedItem);
|
|
100
|
+
setPage(1);
|
|
101
|
+
},
|
|
102
|
+
[],
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
if (isLoading) {
|
|
106
|
+
return <DataTableSkeleton headers={headers} rowCount={PAGE_SIZE} />;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (error) {
|
|
110
|
+
return (
|
|
111
|
+
<InlineNotification
|
|
112
|
+
kind="error"
|
|
113
|
+
title={t('errorLoadingTransactions', 'Error loading transactions')}
|
|
114
|
+
subtitle={error.message}
|
|
115
|
+
/>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<Layer>
|
|
121
|
+
<DataTable rows={rows} headers={headers} isSortable>
|
|
122
|
+
{({
|
|
123
|
+
rows: tableRows,
|
|
124
|
+
headers: tableHeaders,
|
|
125
|
+
getHeaderProps,
|
|
126
|
+
getRowProps,
|
|
127
|
+
getTableProps,
|
|
128
|
+
getTableContainerProps,
|
|
129
|
+
}: DataTableRenderProps) => (
|
|
130
|
+
<TableContainer
|
|
131
|
+
data-testid="edi-transaction-list"
|
|
132
|
+
title={t('ediTransactions', 'EDI Transactions')}
|
|
133
|
+
description={t('transactionListDescription', 'View and manage EDI transaction records')}
|
|
134
|
+
{...getTableContainerProps()}
|
|
135
|
+
>
|
|
136
|
+
<TableToolbar>
|
|
137
|
+
<TableToolbarContent>
|
|
138
|
+
<TableToolbarSearch
|
|
139
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
|
140
|
+
setSearchTerm(e.target.value)
|
|
141
|
+
}
|
|
142
|
+
placeholder={t('searchTransactions', 'Search transactions...')}
|
|
143
|
+
/>
|
|
144
|
+
<Dropdown
|
|
145
|
+
id="edi-status-filter"
|
|
146
|
+
titleText=""
|
|
147
|
+
label={t('filterByStatus', 'Status')}
|
|
148
|
+
items={STATUS_OPTIONS}
|
|
149
|
+
selectedItem={statusFilter}
|
|
150
|
+
onChange={handleStatusFilterChange}
|
|
151
|
+
size="lg"
|
|
152
|
+
/>
|
|
153
|
+
</TableToolbarContent>
|
|
154
|
+
</TableToolbar>
|
|
155
|
+
<Table {...getTableProps()}>
|
|
156
|
+
<TableHead>
|
|
157
|
+
<TableRow>
|
|
158
|
+
{tableHeaders.map((header: CarbonTableHeader) => (
|
|
159
|
+
<TableHeader key={header.key} {...getHeaderProps({ header })}>
|
|
160
|
+
{header.header}
|
|
161
|
+
</TableHeader>
|
|
162
|
+
))}
|
|
163
|
+
</TableRow>
|
|
164
|
+
</TableHead>
|
|
165
|
+
<TableBody>
|
|
166
|
+
{tableRows.map((row: CarbonTableRow) => (
|
|
167
|
+
<TableRow
|
|
168
|
+
key={row.id}
|
|
169
|
+
{...getRowProps({ row })}
|
|
170
|
+
onClick={() => handleRowClick(row.id)}
|
|
171
|
+
style={{ cursor: 'pointer' }}
|
|
172
|
+
>
|
|
173
|
+
{row.cells.map((cell: CarbonTableCell) => (
|
|
174
|
+
<TableCell key={cell.id}>
|
|
175
|
+
{cell.info.header === 'status' ? (
|
|
176
|
+
<StatusBadge status={cell.value} domain="edi" />
|
|
177
|
+
) : (
|
|
178
|
+
cell.value
|
|
179
|
+
)}
|
|
180
|
+
</TableCell>
|
|
181
|
+
))}
|
|
182
|
+
</TableRow>
|
|
183
|
+
))}
|
|
184
|
+
</TableBody>
|
|
185
|
+
</Table>
|
|
186
|
+
<Pagination
|
|
187
|
+
page={page}
|
|
188
|
+
pageSize={PAGE_SIZE}
|
|
189
|
+
totalItems={totalCount}
|
|
190
|
+
onChange={({ page: newPage }: { page: number }) => setPage(newPage)}
|
|
191
|
+
/>
|
|
192
|
+
</TableContainer>
|
|
193
|
+
)}
|
|
194
|
+
</DataTable>
|
|
195
|
+
</Layer>
|
|
196
|
+
);
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
export default TransactionListTable;
|