mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-07 14:15:45 +00:00
Adding license generation functionality
This commit is contained in:
@@ -1,17 +1,15 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Center,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Switch,
|
||||
Table,
|
||||
@@ -19,400 +17,365 @@ import {
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
rem,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconBook2,
|
||||
IconCertificate,
|
||||
IconCheck,
|
||||
IconAlertTriangle,
|
||||
IconCreditCard,
|
||||
IconEdit,
|
||||
IconId,
|
||||
IconInfoCircle,
|
||||
IconShieldCheck,
|
||||
IconLock,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
localized,
|
||||
useGetLicenseTypesQuery,
|
||||
useGetPaymentCapabilitiesQuery,
|
||||
useUpdateLicenseFeesMutation,
|
||||
} from '@ema-platform/api';
|
||||
import type { LicenseType } from '@ema-platform/api';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
interface CertFee {
|
||||
id: string;
|
||||
certType: string;
|
||||
icon: typeof IconBook2;
|
||||
color: string;
|
||||
description: string;
|
||||
fees: { label: string; amount: number; editable: boolean }[];
|
||||
enabled: boolean;
|
||||
/**
|
||||
* 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}`;
|
||||
}
|
||||
|
||||
interface PaymentMethod {
|
||||
id: string;
|
||||
name: string;
|
||||
shortName: string;
|
||||
accountNumber: string;
|
||||
accountName: string;
|
||||
instructions: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Initial state
|
||||
// ---------------------------------------------------------------------------
|
||||
const INITIAL_CERT_FEES: CertFee[] = [
|
||||
{
|
||||
id: 'seaman-book',
|
||||
certType: 'Seaman Book',
|
||||
icon: IconBook2,
|
||||
color: 'blue',
|
||||
description: 'Official EMA seafarer identification book — valid 5 years',
|
||||
fees: [
|
||||
{ label: 'Application Fee', amount: 500, editable: true },
|
||||
{ label: 'Document Verification Fee',amount: 200, editable: true },
|
||||
],
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'btc',
|
||||
certType: 'Basic Training Certificate (BTC)',
|
||||
icon: IconCertificate,
|
||||
color: 'teal',
|
||||
description: 'EMA-issued BTC certifying all 5 basic safety training courses — valid 5 years',
|
||||
fees: [
|
||||
{ label: 'Application Fee', amount: 300, editable: true },
|
||||
{ label: 'Document Verification Fee',amount: 100, editable: true },
|
||||
],
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'bsid',
|
||||
certType: 'BSID (Biometric Seafarer ID)',
|
||||
icon: IconId,
|
||||
color: 'violet',
|
||||
description: 'Biometric Seafarer Identity Document — valid 5 years',
|
||||
fees: [
|
||||
{ label: 'Application Fee', amount: 100, editable: true },
|
||||
{ label: 'Card Production Fee', amount: 150, editable: true },
|
||||
],
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'certificate',
|
||||
certType: 'Certificate (CoC / CoP)',
|
||||
icon: IconShieldCheck,
|
||||
color: 'orange',
|
||||
description: 'Certificate of Competency or Certificate of Proficiency under STCW',
|
||||
fees: [
|
||||
{ label: 'Application Fee', amount: 800, editable: true },
|
||||
{ label: 'Examination Fee', amount: 400, editable: true },
|
||||
{ label: 'Certificate Issuance Fee', amount: 200, editable: true },
|
||||
],
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
const INITIAL_PAYMENT_METHODS: PaymentMethod[] = [
|
||||
{
|
||||
id: 'cbe',
|
||||
name: 'Commercial Bank of Ethiopia',
|
||||
shortName: 'CBE',
|
||||
accountNumber: '1000123456789',
|
||||
accountName: 'EMA Maritime Authority',
|
||||
instructions: 'Transfer the exact fee amount. Use your full name as the transfer description.',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'telebirr',
|
||||
name: 'Telebirr (Ethio Telecom)',
|
||||
shortName: 'Telebirr',
|
||||
accountNumber: '+251 11 551 0000',
|
||||
accountName: 'EMA Maritime Authority',
|
||||
instructions: 'Send to Telebirr number. Screenshot your confirmation and upload it with your application.',
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fee edit modal
|
||||
// ---------------------------------------------------------------------------
|
||||
function FeeEditModal({
|
||||
cert,
|
||||
opened,
|
||||
onClose,
|
||||
onSave,
|
||||
}: {
|
||||
cert: CertFee | null;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (id: string, fees: CertFee['fees']) => void;
|
||||
}) {
|
||||
const [fees, setFees] = useState<CertFee['fees']>(cert?.fees ?? []);
|
||||
|
||||
const handleOpen = () => setFees(cert?.fees ?? []);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={<Group gap="xs"><IconCreditCard size={18} /><Text fw={700}>Edit Fees — {cert?.certType}</Text></Group>}
|
||||
size="md"
|
||||
radius="lg"
|
||||
onTransitionEnd={() => { if (opened) handleOpen(); }}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={15} />}>
|
||||
Changes take effect on new applications immediately. Existing applications retain the fee at time of submission.
|
||||
</Alert>
|
||||
{fees.map((fee, i) => (
|
||||
<Group key={fee.label} gap="sm" align="flex-end">
|
||||
<TextInput label="Fee Label" value={fee.label} disabled style={{ flex: 1 }} size="sm" />
|
||||
<NumberInput
|
||||
label="Amount (ETB)"
|
||||
value={fee.amount}
|
||||
min={0}
|
||||
step={50}
|
||||
style={{ width: rem(140) }}
|
||||
size="sm"
|
||||
disabled={!fee.editable}
|
||||
onChange={(v) =>
|
||||
setFees((prev) => prev.map((f, idx) => idx === i ? { ...f, amount: Number(v) || 0 } : f))
|
||||
}
|
||||
/>
|
||||
</Group>
|
||||
))}
|
||||
<Divider />
|
||||
<Paper withBorder radius="md" p="sm" bg="gray.0">
|
||||
<Group justify="space-between">
|
||||
<Text fz="sm" fw={700}>Total</Text>
|
||||
<Text fz="sm" fw={700} c="blue">ETB {fees.reduce((s, f) => s + f.amount, 0).toFixed(2)}</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose}>Cancel</Button>
|
||||
<Button leftSection={<IconCheck size={15} />} onClick={() => { onSave(cert!.id, fees); onClose(); }}>
|
||||
Save Fees
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Payment method edit modal
|
||||
// ---------------------------------------------------------------------------
|
||||
function MethodEditModal({
|
||||
method,
|
||||
opened,
|
||||
onClose,
|
||||
onSave,
|
||||
}: {
|
||||
method: PaymentMethod | null;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (updated: PaymentMethod) => void;
|
||||
}) {
|
||||
const [form, setForm] = useState<PaymentMethod>(method ?? INITIAL_PAYMENT_METHODS[0]);
|
||||
const set = (k: keyof PaymentMethod, v: string) => setForm((p) => ({ ...p, [k]: v }));
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={<Group gap="xs"><IconCreditCard size={18} /><Text fw={700}>Edit Payment Method — {method?.shortName}</Text></Group>}
|
||||
size="md"
|
||||
radius="lg"
|
||||
onTransitionEnd={() => { if (opened && method) setForm(method); }}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<TextInput label="Account Number / Phone" value={form.accountNumber} onChange={(e) => set('accountNumber', e.currentTarget.value)} />
|
||||
<TextInput label="Account Name" value={form.accountName} onChange={(e) => set('accountName', e.currentTarget.value)} />
|
||||
<TextInput label="Instructions (shown to applicant)" value={form.instructions} onChange={(e) => set('instructions', e.currentTarget.value)} />
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose}>Cancel</Button>
|
||||
<Button leftSection={<IconCheck size={15} />} onClick={() => { onSave(form); onClose(); }}>Save</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main page
|
||||
// ---------------------------------------------------------------------------
|
||||
export function PaymentConfigPage() {
|
||||
const [certFees, setCertFees] = useState<CertFee[]>(INITIAL_CERT_FEES);
|
||||
const [methods, setMethods] = useState<PaymentMethod[]>(INITIAL_PAYMENT_METHODS);
|
||||
const { data, isLoading, error } = useGetLicenseTypesQuery();
|
||||
const { data: capabilities } = useGetPaymentCapabilitiesQuery();
|
||||
const [editing, setEditing] = useState<LicenseType | null>(null);
|
||||
|
||||
const [editingCert, setEditingCert] = useState<CertFee | null>(null);
|
||||
const [editingMethod, setEditingMethod] = useState<PaymentMethod | null>(null);
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center h={300}>
|
||||
<Loader />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
const totalCombined = certFees
|
||||
.filter((c) => c.enabled)
|
||||
.flatMap((c) => c.fees)
|
||||
.reduce((s, f) => s + f.amount, 0);
|
||||
if (error) {
|
||||
return (
|
||||
<Alert
|
||||
color="red"
|
||||
icon={<IconAlertTriangle size={18} />}
|
||||
title="Could not load licence types"
|
||||
>
|
||||
<Text size="sm">{extractErrorMessage(error)}</Text>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const handleSaveFees = (id: string, fees: CertFee['fees']) => {
|
||||
setCertFees((prev) => prev.map((c) => c.id === id ? { ...c, fees } : c));
|
||||
notify.success('Fees updated successfully.');
|
||||
};
|
||||
|
||||
const handleToggleCert = (id: string) => {
|
||||
setCertFees((prev) => prev.map((c) => c.id === id ? { ...c, enabled: !c.enabled } : c));
|
||||
};
|
||||
|
||||
const handleSaveMethod = (updated: PaymentMethod) => {
|
||||
setMethods((prev) => prev.map((m) => m.id === updated.id ? updated : m));
|
||||
notify.success('Payment method updated.');
|
||||
};
|
||||
|
||||
const handleToggleMethod = (id: string) => {
|
||||
setMethods((prev) => prev.map((m) => m.id === id ? { ...m, enabled: !m.enabled } : m));
|
||||
};
|
||||
const types = [...(data?.items ?? [])].sort(
|
||||
(a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0),
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Header */}
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<div>
|
||||
<Title order={3}>Payment Configuration</Title>
|
||||
<Text fz="sm" c="dimmed">Manage certificate fees and accepted payment methods shown to applicants</Text>
|
||||
<Title order={3}>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.
|
||||
</Text>
|
||||
</div>
|
||||
<Badge size="lg" variant="light" color="blue">
|
||||
Combined Total: ETB {totalCombined.toFixed(2)}
|
||||
</Badge>
|
||||
<ThemeIcon size="xl" radius="md" variant="light">
|
||||
<IconCreditCard size={22} />
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
|
||||
{/* Fee summary table */}
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Group justify="space-between" mb="lg">
|
||||
<Text fw={700} fz="md">Certificate Fees</Text>
|
||||
<Text fz="xs" c="dimmed">Click Edit to change fee amounts</Text>
|
||||
</Group>
|
||||
<Alert
|
||||
variant="light"
|
||||
color="blue"
|
||||
radius="md"
|
||||
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.
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<Table highlightOnHover verticalSpacing="sm" fz="sm">
|
||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
||||
<Table.Tr>
|
||||
{['Certificate Type', 'Fee Breakdown', 'Total', 'Status', ''].map((h) => (
|
||||
<Table.Th key={h} style={{ fontSize: rem(11), textTransform: 'uppercase', color: 'var(--mantine-color-dimmed)' }}>{h}</Table.Th>
|
||||
))}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{certFees.map((cert) => {
|
||||
const CertIcon = cert.icon;
|
||||
const total = cert.fees.reduce((s, f) => s + f.amount, 0);
|
||||
return (
|
||||
<Table.Tr key={cert.id} style={{ opacity: cert.enabled ? 1 : 0.5 }}>
|
||||
<Card withBorder radius="md" padding={0}>
|
||||
<Table.ScrollContainer minWidth={820}>
|
||||
<Table highlightOnHover verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Licence type</Table.Th>
|
||||
<Table.Th>New application</Table.Th>
|
||||
<Table.Th>Renewal</Table.Th>
|
||||
<Table.Th>Charged?</Table.Th>
|
||||
<Table.Th w={90} />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{types.map((type) => (
|
||||
<Table.Tr key={type.id}>
|
||||
<Table.Td>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon size="md" variant="light" color={cert.color} radius="md">
|
||||
<CertIcon size={16} stroke={1.5} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fz="sm" fw={600}>{cert.certType}</Text>
|
||||
<Text fz="xs" c="dimmed">{cert.description}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Text size="sm" fw={600}>
|
||||
{localized(type.name)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" ff="monospace">
|
||||
{type.key}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Stack gap={2}>
|
||||
{cert.fees.map((f) => (
|
||||
<Group key={f.label} gap={6}>
|
||||
<Text fz="xs" c="dimmed">{f.label}:</Text>
|
||||
<Text fz="xs" fw={500}>ETB {f.amount.toFixed(2)}</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
<Text size="sm" fw={500}>
|
||||
{feeText(type.feeNewApplication, type.feeCurrency)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz="sm" fw={700} c={cert.enabled ? 'blue.7' : 'dimmed'}>ETB {total.toFixed(2)}</Text>
|
||||
{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">
|
||||
<Text size="sm" c="dimmed">
|
||||
{feeText(type.feeNewApplication, type.feeCurrency)}{' '}
|
||||
<Text span size="xs" c="dimmed">
|
||||
(same as new)
|
||||
</Text>
|
||||
</Text>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Text size="sm" fw={500}>
|
||||
{feeText(type.feeRenewal, type.feeCurrency)}
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Switch
|
||||
checked={cert.enabled}
|
||||
onChange={() => handleToggleCert(cert.id)}
|
||||
size="sm"
|
||||
color="teal"
|
||||
label={cert.enabled ? 'Active' : 'Disabled'}
|
||||
/>
|
||||
{type.issuesCertificate ? (
|
||||
<Badge variant="light" color="teal" size="sm">
|
||||
On approval
|
||||
</Badge>
|
||||
) : (
|
||||
<Tooltip label="This licence type ends with an EMA decision and never reaches a payment stage">
|
||||
<Badge variant="light" color="gray" size="sm">
|
||||
Not charged
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<ActionIcon variant="light" color="blue" size="md" onClick={() => setEditingCert(cert)}>
|
||||
<IconEdit size={15} />
|
||||
</ActionIcon>
|
||||
<Table.Td align="right">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
leftSection={<IconEdit size={14} />}
|
||||
onClick={() => setEditing(type)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
<Table.Tfoot>
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={2}><Text fz="sm" fw={700} ta="right">Grand Total (all active)</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm" fw={800} c="blue">ETB {totalCombined.toFixed(2)}</Text></Table.Td>
|
||||
<Table.Td colSpan={2} />
|
||||
</Table.Tr>
|
||||
</Table.Tfoot>
|
||||
</Table>
|
||||
</Paper>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
</Card>
|
||||
|
||||
{/* Payment methods */}
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Text fw={700} fz="md" mb="lg">Accepted Payment Methods</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
{methods.map((method) => (
|
||||
<Card key={method.id} withBorder radius="md" p="md" style={{ opacity: method.enabled ? 1 : 0.6 }}>
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size="lg" variant="light" color={method.enabled ? 'blue' : 'gray'} radius="md">
|
||||
<IconCreditCard size={18} stroke={1.5} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} fz="sm">{method.name}</Text>
|
||||
<Badge size="xs" variant="light" color={method.enabled ? 'teal' : 'gray'}>
|
||||
{method.enabled ? 'Active' : 'Disabled'}
|
||||
</Badge>
|
||||
</div>
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
<Switch checked={method.enabled} onChange={() => handleToggleMethod(method.id)} size="sm" color="teal" />
|
||||
<ActionIcon variant="light" color="blue" size="md" onClick={() => setEditingMethod(method)}>
|
||||
<IconEdit size={15} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
<Divider mb="sm" />
|
||||
<Stack gap={4}>
|
||||
<Group justify="space-between">
|
||||
<Text fz="xs" c="dimmed">Account / Number</Text>
|
||||
<Text fz="xs" fw={600}>{method.accountNumber}</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text fz="xs" c="dimmed">Account Name</Text>
|
||||
<Text fz="xs" fw={600}>{method.accountName}</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
<Text fz="xs" c="dimmed" mt="sm" lh={1.4}>{method.instructions}</Text>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
<GatewayPanel bypassEnabled={capabilities?.bypassEnabled} />
|
||||
|
||||
{/* Fee edit modal */}
|
||||
<FeeEditModal
|
||||
cert={editingCert}
|
||||
opened={!!editingCert}
|
||||
onClose={() => setEditingCert(null)}
|
||||
onSave={handleSaveFees}
|
||||
/>
|
||||
|
||||
{/* Method edit modal */}
|
||||
<MethodEditModal
|
||||
method={editingMethod}
|
||||
opened={!!editingMethod}
|
||||
onClose={() => setEditingMethod(null)}
|
||||
onSave={handleSaveMethod}
|
||||
/>
|
||||
<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 }) {
|
||||
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">
|
||||
Payment gateway
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Set by the payment service environment — not editable here.
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap="sm">
|
||||
<Badge variant="light">Telebirr</Badge>
|
||||
{bypassEnabled ? (
|
||||
<Tooltip label="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
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Badge variant="light" color="gray">
|
||||
Test bypass off
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function FeeEditModal({
|
||||
licenseType,
|
||||
onClose,
|
||||
}: {
|
||||
licenseType: LicenseType | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
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('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.');
|
||||
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(`${localized(licenseType.name)} fees updated.`);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
notify.error(extractErrorMessage(err, 'Could not save the fees'));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={!!licenseType}
|
||||
onClose={onClose}
|
||||
title={licenseType ? `Fees — ${localized(licenseType.name)}` : ''}
|
||||
centered
|
||||
radius="lg"
|
||||
>
|
||||
{licenseType && (
|
||||
<Stack gap="md">
|
||||
{!licenseType.issuesCertificate && (
|
||||
<Alert
|
||||
variant="light"
|
||||
color="gray"
|
||||
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.
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<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."
|
||||
/>
|
||||
|
||||
{chargeable && (
|
||||
<>
|
||||
<NumberInput
|
||||
label="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="Charge renewal at the same rate"
|
||||
description="Turn off to set a separate renewal fee."
|
||||
/>
|
||||
|
||||
{!sameAsNew && (
|
||||
<NumberInput
|
||||
label="Renewal fee"
|
||||
value={renewalFee}
|
||||
onChange={(v) => setRenewalFee(v === '' ? '' : Number(v))}
|
||||
min={0}
|
||||
max={9_999_999_999}
|
||||
thousandSeparator=","
|
||||
allowNegative={false}
|
||||
decimalScale={2}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
label="Currency"
|
||||
value={currency}
|
||||
onChange={(e) =>
|
||||
setCurrency(e.currentTarget.value.toUpperCase())
|
||||
}
|
||||
maxLength={8}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end" mt="xs">
|
||||
<Button variant="default" onClick={onClose} disabled={isLoading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={save} loading={isLoading}>
|
||||
Save fees
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default PaymentConfigPage;
|
||||
|
||||
Reference in New Issue
Block a user