Adding license generation functionality

This commit is contained in:
Muluhabt
2026-07-29 16:53:50 +03:00
parent 26cc1f4fd2
commit 5161e278ce
102 changed files with 5995 additions and 21326 deletions

View File

@@ -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;