mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-30 01:48:13 +00:00
- Added useLocalized hook to provide a stable function for retrieving bilingual values based on the current language. - Updated various components across the portal and backoffice to utilize the new useLocalized hook for consistent bilingual label rendering. - Refactored localized function in licensing.helpers to handle empty Amharic strings correctly. - Enhanced localization handling in LicenseApplicationPage, ProfilePage, and other components to ensure proper language switching.
466 lines
14 KiB
TypeScript
466 lines
14 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import {
|
|
Alert,
|
|
Badge,
|
|
Button,
|
|
Center,
|
|
Group,
|
|
Loader,
|
|
Modal,
|
|
NumberInput,
|
|
Paper,
|
|
Stack,
|
|
Switch,
|
|
Text,
|
|
TextInput,
|
|
ThemeIcon,
|
|
Title,
|
|
Tooltip,
|
|
} from '@mantine/core';
|
|
import {
|
|
IconAlertTriangle,
|
|
IconCreditCard,
|
|
IconEdit,
|
|
IconInfoCircle,
|
|
IconLock,
|
|
} from '@tabler/icons-react';
|
|
import {
|
|
notify,
|
|
ModalFooter,
|
|
AdvancedTable,
|
|
useServerTable,
|
|
type AdvancedColumn,
|
|
} from '@ema-platform/ui';
|
|
import {
|
|
extractErrorMessage,
|
|
useLocalized,
|
|
useGetLicenseTypesQuery,
|
|
useGetPaymentCapabilitiesQuery,
|
|
useUpdateLicenseFeesMutation,
|
|
} from '@ema-platform/api';
|
|
import type { LicenseType } from '@ema-platform/api';
|
|
|
|
/**
|
|
* Licence fee configuration.
|
|
*
|
|
* The amounts live on the licence type itself, which is what the workflow
|
|
* reads when it raises a payment — so what is edited here is the same value
|
|
* the applicant is charged, not a parallel copy of it.
|
|
*
|
|
* Saving is guarded server-side by `can:update:license-type`; an officer
|
|
* without that permission can read the figures but the save is refused.
|
|
*/
|
|
|
|
function feeText(amount: string | number | null, currency: string): string {
|
|
if (amount === null || amount === '') return '—';
|
|
const value = Number(amount);
|
|
if (!Number.isFinite(value)) return '—';
|
|
return `${value.toLocaleString('en-US')} ${currency}`;
|
|
}
|
|
|
|
export function PaymentConfigPage() {
|
|
const { t } = useTranslation();
|
|
const localized = useLocalized();
|
|
const { data, isLoading, isFetching, error, refetch } =
|
|
useGetLicenseTypesQuery();
|
|
const { data: capabilities } = useGetPaymentCapabilitiesQuery();
|
|
const [editing, setEditing] = useState<LicenseType | null>(null);
|
|
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable();
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<Center h={300}>
|
|
<Loader />
|
|
</Center>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<Alert
|
|
color="red"
|
|
icon={<IconAlertTriangle size={18} />}
|
|
title={t('paymentConfig.loadError', 'Could not load licence types')}
|
|
>
|
|
<Text size="sm">{extractErrorMessage(error)}</Text>
|
|
</Alert>
|
|
);
|
|
}
|
|
|
|
const types = [...(data?.items ?? [])].sort(
|
|
(a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0),
|
|
);
|
|
const page = paginate(types);
|
|
|
|
const columns: AdvancedColumn<LicenseType>[] = [
|
|
{
|
|
header: t('paymentConfig.columns.type', 'Licence type'),
|
|
cell: ({ row }) => (
|
|
<>
|
|
<Text size="sm" fw={600}>
|
|
{localized(row.original.name)}
|
|
</Text>
|
|
<Text size="xs" c="dimmed" ff="monospace">
|
|
{row.original.key}
|
|
</Text>
|
|
</>
|
|
),
|
|
},
|
|
{
|
|
header: t('paymentConfig.columns.newApplication', 'New application'),
|
|
cell: ({ row }) => (
|
|
<Text size="sm" fw={500}>
|
|
{feeText(row.original.feeNewApplication, row.original.feeCurrency)}
|
|
</Text>
|
|
),
|
|
},
|
|
{
|
|
header: t('paymentConfig.columns.renewal', 'Renewal'),
|
|
cell: ({ row }) => {
|
|
const type = row.original;
|
|
if (type.feeNewApplication === null) {
|
|
// No charge at all, so "same as new" would be noise.
|
|
return (
|
|
<Text size="sm" c="dimmed">
|
|
—
|
|
</Text>
|
|
);
|
|
}
|
|
if (type.feeRenewal === null) {
|
|
return (
|
|
<Tooltip
|
|
label={t(
|
|
'paymentConfig.renewalSameTooltip',
|
|
'No separate renewal fee — renewal is charged at the new-application rate',
|
|
)}
|
|
>
|
|
<Text size="sm" c="dimmed">
|
|
{feeText(type.feeNewApplication, type.feeCurrency)}{' '}
|
|
<Text span size="xs" c="dimmed">
|
|
{t('paymentConfig.renewalSameAsNew', '(same as new)')}
|
|
</Text>
|
|
</Text>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
return (
|
|
<Text size="sm" fw={500}>
|
|
{feeText(type.feeRenewal, type.feeCurrency)}
|
|
</Text>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
header: t('paymentConfig.columns.charged', 'Charged?'),
|
|
cell: ({ row }) =>
|
|
row.original.issuesCertificate ? (
|
|
<Badge variant="light" color="teal" size="sm">
|
|
{t('paymentConfig.chargedOnApproval', 'On approval')}
|
|
</Badge>
|
|
) : (
|
|
<Tooltip
|
|
label={t(
|
|
'paymentConfig.chargedNotChargedTooltip',
|
|
'This licence type ends with an EMA decision and never reaches a payment stage',
|
|
)}
|
|
>
|
|
<Badge variant="light" color="gray" size="sm">
|
|
{t('paymentConfig.chargedNotCharged', 'Not charged')}
|
|
</Badge>
|
|
</Tooltip>
|
|
),
|
|
},
|
|
{
|
|
header: '',
|
|
size: 90,
|
|
align: 'right',
|
|
cell: ({ row }) => (
|
|
<Button
|
|
size="xs"
|
|
variant="light"
|
|
leftSection={<IconEdit size={14} />}
|
|
onClick={() => setEditing(row.original)}
|
|
>
|
|
{t('paymentConfig.edit', 'Edit')}
|
|
</Button>
|
|
),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<Stack gap="lg">
|
|
<Group justify="space-between" align="flex-start">
|
|
<div>
|
|
<Title order={3}>{t('paymentConfig.title', 'Payment configuration')}</Title>
|
|
<Text size="sm" c="dimmed" mt={4}>
|
|
{t(
|
|
'paymentConfig.subtitle',
|
|
'What each licence costs. Applicants are charged after approval, and the amount is fixed onto the application at that moment.',
|
|
)}
|
|
</Text>
|
|
</div>
|
|
<ThemeIcon size="xl" radius="md" variant="light">
|
|
<IconCreditCard size={22} />
|
|
</ThemeIcon>
|
|
</Group>
|
|
|
|
<Alert
|
|
variant="light"
|
|
color="blue"
|
|
radius="md"
|
|
icon={<IconInfoCircle size={18} />}
|
|
>
|
|
<Text size="sm">
|
|
{t(
|
|
'paymentConfig.changeNotice',
|
|
'A change applies to applications approved from now on. Anything already approved keeps the amount it was quoted, so an edit here can never alter what an applicant has already been asked to pay.',
|
|
)}
|
|
</Text>
|
|
</Alert>
|
|
|
|
<AdvancedTable
|
|
tableName="payment-config-license-types"
|
|
columns={columns}
|
|
data={page.rows}
|
|
itemCount={page.itemCount}
|
|
pageIndex={page.pageIndex}
|
|
onPageChange={setPageIndex}
|
|
pageSize={pageSize}
|
|
onPageSizeChange={setPageSize}
|
|
refresh={refetch}
|
|
isLoading={isFetching}
|
|
/>
|
|
|
|
<GatewayPanel bypassEnabled={capabilities?.bypassEnabled} />
|
|
|
|
<FeeEditModal licenseType={editing} onClose={() => setEditing(null)} />
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* How payments are actually collected. Read-only on purpose: these come from
|
|
* the payment service's environment rather than the database, so rendering
|
|
* them as editable fields would be a lie.
|
|
*/
|
|
function GatewayPanel({ bypassEnabled }: { bypassEnabled?: boolean }) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<Paper withBorder radius="md" p="md">
|
|
<Group gap="xs" mb="xs">
|
|
<ThemeIcon size="sm" radius="xl" variant="light" color="gray">
|
|
<IconLock size={13} />
|
|
</ThemeIcon>
|
|
<Text fw={600} size="sm">
|
|
{t('paymentConfig.gateway.title', 'Payment gateway')}
|
|
</Text>
|
|
<Text size="xs" c="dimmed">
|
|
{t(
|
|
'paymentConfig.gateway.subtitle',
|
|
'Set by the payment service environment — not editable here.',
|
|
)}
|
|
</Text>
|
|
</Group>
|
|
<Group gap="sm">
|
|
<Badge variant="light">{t('paymentConfig.gateway.provider', 'Telebirr')}</Badge>
|
|
{bypassEnabled ? (
|
|
<Tooltip
|
|
label={t(
|
|
'paymentConfig.gateway.bypassTooltip',
|
|
'ALLOW_PAYMENT_BYPASS is on, so an applicant can settle a fee without paying. It is refused in production.',
|
|
)}
|
|
>
|
|
<Badge variant="light" color="orange">
|
|
{t('paymentConfig.gateway.bypassEnabled', 'Test bypass enabled')}
|
|
</Badge>
|
|
</Tooltip>
|
|
) : (
|
|
<Badge variant="light" color="gray">
|
|
{t('paymentConfig.gateway.bypassOff', 'Test bypass off')}
|
|
</Badge>
|
|
)}
|
|
</Group>
|
|
</Paper>
|
|
);
|
|
}
|
|
|
|
function FeeEditModal({
|
|
licenseType,
|
|
onClose,
|
|
}: {
|
|
licenseType: LicenseType | null;
|
|
onClose: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const localized = useLocalized();
|
|
const [updateFees, { isLoading }] = useUpdateLicenseFeesMutation();
|
|
const [newFee, setNewFee] = useState<number | ''>('');
|
|
const [renewalFee, setRenewalFee] = useState<number | ''>('');
|
|
const [currency, setCurrency] = useState('ETB');
|
|
// Held separately from an empty amount: "carries no fee" and "same as new"
|
|
// are real configuration states, not blank fields.
|
|
const [chargeable, setChargeable] = useState(true);
|
|
const [sameAsNew, setSameAsNew] = useState(true);
|
|
|
|
useEffect(() => {
|
|
if (!licenseType) return;
|
|
setChargeable(licenseType.feeNewApplication !== null);
|
|
setNewFee(
|
|
licenseType.feeNewApplication === null
|
|
? ''
|
|
: Number(licenseType.feeNewApplication),
|
|
);
|
|
setSameAsNew(licenseType.feeRenewal === null);
|
|
setRenewalFee(
|
|
licenseType.feeRenewal === null ? '' : Number(licenseType.feeRenewal),
|
|
);
|
|
setCurrency(licenseType.feeCurrency || 'ETB');
|
|
}, [licenseType]);
|
|
|
|
async function save() {
|
|
if (!licenseType) return;
|
|
if (chargeable && newFee === '') {
|
|
notify.error(
|
|
t(
|
|
'paymentConfig.modal.missingNewFee',
|
|
'Enter a new-application fee, or turn off "carries a fee".',
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
if (chargeable && !sameAsNew && renewalFee === '') {
|
|
notify.error(
|
|
t(
|
|
'paymentConfig.modal.missingRenewalFee',
|
|
'Enter a renewal fee, or charge renewal at the same rate.',
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
try {
|
|
await updateFees({
|
|
id: licenseType.id,
|
|
feeNewApplication: chargeable ? Number(newFee) : null,
|
|
feeRenewal: chargeable && !sameAsNew ? Number(renewalFee) : null,
|
|
feeCurrency: currency.trim() || 'ETB',
|
|
}).unwrap();
|
|
notify.success(
|
|
t('paymentConfig.modal.updated', {
|
|
type: localized(licenseType.name),
|
|
defaultValue: '{{type}} fees updated.',
|
|
}),
|
|
);
|
|
onClose();
|
|
} catch (err) {
|
|
notify.error(
|
|
extractErrorMessage(err, t('paymentConfig.modal.saveFailed', 'Could not save the fees')),
|
|
);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Modal
|
|
opened={!!licenseType}
|
|
onClose={onClose}
|
|
title={
|
|
licenseType
|
|
? t('paymentConfig.modal.title', {
|
|
type: localized(licenseType.name),
|
|
defaultValue: 'Fees — {{type}}',
|
|
})
|
|
: ''
|
|
}
|
|
centered
|
|
radius="lg"
|
|
>
|
|
{licenseType && (
|
|
<Stack gap="md">
|
|
{!licenseType.issuesCertificate && (
|
|
<Alert
|
|
variant="light"
|
|
color="gray"
|
|
icon={<IconInfoCircle size={16} />}
|
|
>
|
|
<Text size="sm">
|
|
{t(
|
|
'paymentConfig.modal.noPaymentStage',
|
|
'This licence type concludes with an EMA decision and never reaches a payment stage, so a fee set here stays unused until that changes.',
|
|
)}
|
|
</Text>
|
|
</Alert>
|
|
)}
|
|
|
|
<Switch
|
|
checked={chargeable}
|
|
onChange={(e) => setChargeable(e.currentTarget.checked)}
|
|
label={t('paymentConfig.modal.chargeableLabel', 'This licence carries a fee')}
|
|
description={t(
|
|
'paymentConfig.modal.chargeableDescription',
|
|
'Turn off for licence types applicants are never charged for.',
|
|
)}
|
|
/>
|
|
|
|
{chargeable && (
|
|
<>
|
|
<NumberInput
|
|
label={t('paymentConfig.modal.newFeeLabel', 'New application fee')}
|
|
value={newFee}
|
|
onChange={(v) => setNewFee(v === '' ? '' : Number(v))}
|
|
min={0}
|
|
max={9_999_999_999}
|
|
thousandSeparator=","
|
|
allowNegative={false}
|
|
decimalScale={2}
|
|
/>
|
|
|
|
<Switch
|
|
checked={sameAsNew}
|
|
onChange={(e) => setSameAsNew(e.currentTarget.checked)}
|
|
label={t('paymentConfig.modal.sameRateLabel', 'Charge renewal at the same rate')}
|
|
description={t(
|
|
'paymentConfig.modal.sameRateDescription',
|
|
'Turn off to set a separate renewal fee.',
|
|
)}
|
|
/>
|
|
|
|
{!sameAsNew && (
|
|
<NumberInput
|
|
label={t('paymentConfig.modal.renewalFeeLabel', 'Renewal fee')}
|
|
value={renewalFee}
|
|
onChange={(v) => setRenewalFee(v === '' ? '' : Number(v))}
|
|
min={0}
|
|
max={9_999_999_999}
|
|
thousandSeparator=","
|
|
allowNegative={false}
|
|
decimalScale={2}
|
|
/>
|
|
)}
|
|
|
|
<TextInput
|
|
label={t('paymentConfig.modal.currencyLabel', 'Currency')}
|
|
value={currency}
|
|
onChange={(e) =>
|
|
setCurrency(e.currentTarget.value.toUpperCase())
|
|
}
|
|
maxLength={8}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
<ModalFooter mt="xs">
|
|
<Button variant="default" onClick={onClose} disabled={isLoading}>
|
|
{t('paymentConfig.modal.cancel', 'Cancel')}
|
|
</Button>
|
|
<Button onClick={save} loading={isLoading}>
|
|
{t('paymentConfig.modal.save', 'Save fees')}
|
|
</Button>
|
|
</ModalFooter>
|
|
</Stack>
|
|
)}
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
export default PaymentConfigPage;
|