mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-07 21:15:42 +00:00
Merge branch 'dev' of github.com:Tria-plc/emaui into Refactor
Resolved by unifying on the lib/data AdvancedTable (PR #10) as the canonical table component: kept its API plus teammate i18n/feature work, kept the folder-per-table structure (index.tsx + columns.tsx + actions.tsx) across all 27 tables, removed the parallel lib/table implementation, and fixed pre-existing compile breaks in ExamDetailPage/QuestionAssigner/RecordResultModal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
import { Button } from '@mantine/core';
|
||||
import { IconEdit } from '@tabler/icons-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { LicenseType } from '@ema-platform/api';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
|
||||
export function paymentConfigActionsColumn(
|
||||
t: TFunction,
|
||||
handlers: { onEdit: (type: LicenseType) => void },
|
||||
): AdvancedColumn<LicenseType> {
|
||||
return {
|
||||
header: '',
|
||||
size: 90,
|
||||
align: 'right',
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
leftSection={<IconEdit size={14} />}
|
||||
onClick={() => handlers.onEdit(row.original)}
|
||||
>
|
||||
{t('paymentConfig.edit', 'Edit')}
|
||||
</Button>
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Badge, Button, Text, Tooltip } from '@mantine/core';
|
||||
import { IconEdit } from '@tabler/icons-react';
|
||||
import type { AdvancedTableColumn } from '@ema-platform/ui';
|
||||
import { localized } from '@ema-platform/api';
|
||||
import { Badge, Text, Tooltip } from '@mantine/core';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { LicenseType } from '@ema-platform/api';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
|
||||
function feeText(amount: string | number | null, currency: string): string {
|
||||
if (amount === null || amount === '') return '—';
|
||||
@@ -11,89 +10,87 @@ function feeText(amount: string | number | null, currency: string): string {
|
||||
return `${value.toLocaleString('en-US')} ${currency}`;
|
||||
}
|
||||
|
||||
/** Edit stays a text button by design — kept as a rendered column. */
|
||||
export function paymentConfigColumns(handlers: {
|
||||
onEdit: (type: LicenseType) => void;
|
||||
}): AdvancedTableColumn<LicenseType>[] {
|
||||
export function paymentConfigColumns(
|
||||
t: TFunction,
|
||||
localized: (value: LicenseType['name']) => string,
|
||||
): AdvancedColumn<LicenseType>[] {
|
||||
return [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Licence type',
|
||||
render: (type) => (
|
||||
header: t('paymentConfig.columns.type', 'Licence type'),
|
||||
cell: ({ row }) => (
|
||||
<>
|
||||
<Text size="sm" fw={600}>
|
||||
{localized(type.name)}
|
||||
{localized(row.original.name)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" ff="monospace">
|
||||
{type.key}
|
||||
{row.original.key}
|
||||
</Text>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'feeNewApplication',
|
||||
header: 'New application',
|
||||
render: (type) => (
|
||||
header: t('paymentConfig.columns.newApplication', 'New application'),
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" fw={500}>
|
||||
{feeText(type.feeNewApplication, type.feeCurrency)}
|
||||
{feeText(row.original.feeNewApplication, row.original.feeCurrency)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'feeRenewal',
|
||||
header: 'Renewal',
|
||||
render: (type) =>
|
||||
type.feeNewApplication === null ? (
|
||||
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.
|
||||
<Text size="sm" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
) : type.feeRenewal === null ? (
|
||||
<Tooltip label="No separate renewal fee — renewal is charged at the new-application rate">
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
{feeText(type.feeNewApplication, type.feeCurrency)}{' '}
|
||||
<Text span size="xs" c="dimmed">
|
||||
(same as new)
|
||||
</Text>
|
||||
—
|
||||
</Text>
|
||||
</Tooltip>
|
||||
) : (
|
||||
);
|
||||
}
|
||||
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>
|
||||
),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'charged',
|
||||
header: 'Charged?',
|
||||
render: (type) =>
|
||||
type.issuesCertificate ? (
|
||||
header: t('paymentConfig.columns.charged', 'Charged?'),
|
||||
cell: ({ row }) =>
|
||||
row.original.issuesCertificate ? (
|
||||
<Badge variant="light" color="teal" size="sm">
|
||||
On approval
|
||||
{t('paymentConfig.chargedOnApproval', 'On approval')}
|
||||
</Badge>
|
||||
) : (
|
||||
<Tooltip label="This licence type ends with an EMA decision and never reaches a payment stage">
|
||||
<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">
|
||||
Not charged
|
||||
{t('paymentConfig.chargedNotCharged', 'Not charged')}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'edit',
|
||||
header: '',
|
||||
width: 90,
|
||||
align: 'right',
|
||||
render: (type) => (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
leftSection={<IconEdit size={14} />}
|
||||
onClick={() => handlers.onEdit(type)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Group,
|
||||
Loader,
|
||||
@@ -24,16 +24,23 @@ import {
|
||||
IconInfoCircle,
|
||||
IconLock,
|
||||
} from '@tabler/icons-react';
|
||||
import { AdvancedTable, notify } from '@ema-platform/ui';
|
||||
import {
|
||||
notify,
|
||||
ModalFooter,
|
||||
AdvancedTable,
|
||||
useServerTable,
|
||||
type AdvancedColumn,
|
||||
} from '@ema-platform/ui';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
localized,
|
||||
useLocalized,
|
||||
useGetLicenseTypesQuery,
|
||||
useGetPaymentCapabilitiesQuery,
|
||||
useUpdateLicenseFeesMutation,
|
||||
} from '@ema-platform/api';
|
||||
import type { LicenseType } from '@ema-platform/api';
|
||||
import { paymentConfigColumns } from './columns';
|
||||
import { paymentConfigActionsColumn } from './actions';
|
||||
|
||||
/**
|
||||
* Licence fee configuration.
|
||||
@@ -47,9 +54,13 @@ import { paymentConfigColumns } from './columns';
|
||||
*/
|
||||
|
||||
export function PaymentConfigPage() {
|
||||
const { data, isLoading, error, refetch } = useGetLicenseTypesQuery();
|
||||
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 (
|
||||
@@ -64,7 +75,7 @@ export function PaymentConfigPage() {
|
||||
<Alert
|
||||
color="red"
|
||||
icon={<IconAlertTriangle size={18} />}
|
||||
title="Could not load licence types"
|
||||
title={t('paymentConfig.loadError', 'Could not load licence types')}
|
||||
>
|
||||
<Text size="sm">{extractErrorMessage(error)}</Text>
|
||||
</Alert>
|
||||
@@ -74,15 +85,23 @@ export function PaymentConfigPage() {
|
||||
const types = [...(data?.items ?? [])].sort(
|
||||
(a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0),
|
||||
);
|
||||
const page = paginate(types);
|
||||
|
||||
const columns: AdvancedColumn<LicenseType>[] = [
|
||||
...paymentConfigColumns(t, localized),
|
||||
paymentConfigActionsColumn(t, { onEdit: setEditing }),
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<div>
|
||||
<Title order={3}>Payment configuration</Title>
|
||||
<Title order={3}>{t('paymentConfig.title', 'Payment configuration')}</Title>
|
||||
<Text size="sm" c="dimmed" mt={4}>
|
||||
What each licence costs. Applicants are charged after approval, and
|
||||
the amount is fixed onto the application at that moment.
|
||||
{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">
|
||||
@@ -97,22 +116,25 @@ export function PaymentConfigPage() {
|
||||
icon={<IconInfoCircle size={18} />}
|
||||
>
|
||||
<Text size="sm">
|
||||
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.
|
||||
{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>
|
||||
|
||||
<Card withBorder radius="md" padding={0}>
|
||||
<AdvancedTable
|
||||
columns={paymentConfigColumns({ onEdit: setEditing })}
|
||||
data={types}
|
||||
rowKey={(type) => type.id}
|
||||
onRefresh={refetch}
|
||||
minWidth={820}
|
||||
verticalSpacing="sm"
|
||||
/>
|
||||
</Card>
|
||||
<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} />
|
||||
|
||||
@@ -127,6 +149,7 @@ export function PaymentConfigPage() {
|
||||
* 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">
|
||||
@@ -134,23 +157,31 @@ function GatewayPanel({ bypassEnabled }: { bypassEnabled?: boolean }) {
|
||||
<IconLock size={13} />
|
||||
</ThemeIcon>
|
||||
<Text fw={600} size="sm">
|
||||
Payment gateway
|
||||
{t('paymentConfig.gateway.title', 'Payment gateway')}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Set by the payment service environment — not editable here.
|
||||
{t(
|
||||
'paymentConfig.gateway.subtitle',
|
||||
'Set by the payment service environment — not editable here.',
|
||||
)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap="sm">
|
||||
<Badge variant="light">Telebirr</Badge>
|
||||
<Badge variant="light">{t('paymentConfig.gateway.provider', 'Telebirr')}</Badge>
|
||||
{bypassEnabled ? (
|
||||
<Tooltip label="ALLOW_PAYMENT_BYPASS is on, so an applicant can settle a fee without paying. It is refused in production.">
|
||||
<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">
|
||||
Test bypass enabled
|
||||
{t('paymentConfig.gateway.bypassEnabled', 'Test bypass enabled')}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Badge variant="light" color="gray">
|
||||
Test bypass off
|
||||
{t('paymentConfig.gateway.bypassOff', 'Test bypass off')}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
@@ -165,6 +196,8 @@ function FeeEditModal({
|
||||
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 | ''>('');
|
||||
@@ -192,11 +225,21 @@ function FeeEditModal({
|
||||
async function save() {
|
||||
if (!licenseType) return;
|
||||
if (chargeable && newFee === '') {
|
||||
notify.error('Enter a new-application fee, or turn off "carries a fee".');
|
||||
notify.error(
|
||||
t(
|
||||
'paymentConfig.modal.missingNewFee',
|
||||
'Enter a new-application fee, or turn off "carries a fee".',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (chargeable && !sameAsNew && renewalFee === '') {
|
||||
notify.error('Enter a renewal fee, or charge renewal at the same rate.');
|
||||
notify.error(
|
||||
t(
|
||||
'paymentConfig.modal.missingRenewalFee',
|
||||
'Enter a renewal fee, or charge renewal at the same rate.',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -206,10 +249,17 @@ function FeeEditModal({
|
||||
feeRenewal: chargeable && !sameAsNew ? Number(renewalFee) : null,
|
||||
feeCurrency: currency.trim() || 'ETB',
|
||||
}).unwrap();
|
||||
notify.success(`${localized(licenseType.name)} fees updated.`);
|
||||
notify.success(
|
||||
t('paymentConfig.modal.updated', {
|
||||
type: localized(licenseType.name),
|
||||
defaultValue: '{{type}} fees updated.',
|
||||
}),
|
||||
);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
notify.error(extractErrorMessage(err, 'Could not save the fees'));
|
||||
notify.error(
|
||||
extractErrorMessage(err, t('paymentConfig.modal.saveFailed', 'Could not save the fees')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,7 +267,14 @@ function FeeEditModal({
|
||||
<Modal
|
||||
opened={!!licenseType}
|
||||
onClose={onClose}
|
||||
title={licenseType ? `Fees — ${localized(licenseType.name)}` : ''}
|
||||
title={
|
||||
licenseType
|
||||
? t('paymentConfig.modal.title', {
|
||||
type: localized(licenseType.name),
|
||||
defaultValue: 'Fees — {{type}}',
|
||||
})
|
||||
: ''
|
||||
}
|
||||
centered
|
||||
radius="lg"
|
||||
>
|
||||
@@ -230,9 +287,10 @@ function FeeEditModal({
|
||||
icon={<IconInfoCircle size={16} />}
|
||||
>
|
||||
<Text size="sm">
|
||||
This licence type concludes with an EMA decision and never
|
||||
reaches a payment stage, so a fee set here stays unused until
|
||||
that changes.
|
||||
{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>
|
||||
)}
|
||||
@@ -240,14 +298,17 @@ function FeeEditModal({
|
||||
<Switch
|
||||
checked={chargeable}
|
||||
onChange={(e) => setChargeable(e.currentTarget.checked)}
|
||||
label="This licence carries a fee"
|
||||
description="Turn off for licence types applicants are never charged for."
|
||||
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="New application fee"
|
||||
label={t('paymentConfig.modal.newFeeLabel', 'New application fee')}
|
||||
value={newFee}
|
||||
onChange={(v) => setNewFee(v === '' ? '' : Number(v))}
|
||||
min={0}
|
||||
@@ -260,13 +321,16 @@ function FeeEditModal({
|
||||
<Switch
|
||||
checked={sameAsNew}
|
||||
onChange={(e) => setSameAsNew(e.currentTarget.checked)}
|
||||
label="Charge renewal at the same rate"
|
||||
description="Turn off to set a separate renewal fee."
|
||||
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="Renewal fee"
|
||||
label={t('paymentConfig.modal.renewalFeeLabel', 'Renewal fee')}
|
||||
value={renewalFee}
|
||||
onChange={(v) => setRenewalFee(v === '' ? '' : Number(v))}
|
||||
min={0}
|
||||
@@ -278,7 +342,7 @@ function FeeEditModal({
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
label="Currency"
|
||||
label={t('paymentConfig.modal.currencyLabel', 'Currency')}
|
||||
value={currency}
|
||||
onChange={(e) =>
|
||||
setCurrency(e.currentTarget.value.toUpperCase())
|
||||
@@ -288,14 +352,14 @@ function FeeEditModal({
|
||||
</>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end" mt="xs">
|
||||
<ModalFooter mt="xs">
|
||||
<Button variant="default" onClick={onClose} disabled={isLoading}>
|
||||
Cancel
|
||||
{t('paymentConfig.modal.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button onClick={save} loading={isLoading}>
|
||||
Save fees
|
||||
{t('paymentConfig.modal.save', 'Save fees')}
|
||||
</Button>
|
||||
</Group>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
Reference in New Issue
Block a user