mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-29 14:08:12 +00:00
419 lines
14 KiB
TypeScript
419 lines
14 KiB
TypeScript
import { useState } from 'react';
|
|
import {
|
|
ActionIcon,
|
|
Alert,
|
|
Badge,
|
|
Button,
|
|
Card,
|
|
Divider,
|
|
Group,
|
|
Modal,
|
|
NumberInput,
|
|
Paper,
|
|
Select,
|
|
SimpleGrid,
|
|
Stack,
|
|
Switch,
|
|
Table,
|
|
Text,
|
|
TextInput,
|
|
ThemeIcon,
|
|
Title,
|
|
rem,
|
|
} from '@mantine/core';
|
|
import {
|
|
IconBook2,
|
|
IconCertificate,
|
|
IconCheck,
|
|
IconCreditCard,
|
|
IconEdit,
|
|
IconId,
|
|
IconInfoCircle,
|
|
IconShieldCheck,
|
|
} from '@tabler/icons-react';
|
|
import { notify } from '@ema-platform/ui';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
interface CertFee {
|
|
id: string;
|
|
certType: string;
|
|
icon: typeof IconBook2;
|
|
color: string;
|
|
description: string;
|
|
fees: { label: string; amount: number; editable: boolean }[];
|
|
enabled: boolean;
|
|
}
|
|
|
|
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 [editingCert, setEditingCert] = useState<CertFee | null>(null);
|
|
const [editingMethod, setEditingMethod] = useState<PaymentMethod | null>(null);
|
|
|
|
const totalCombined = certFees
|
|
.filter((c) => c.enabled)
|
|
.flatMap((c) => c.fees)
|
|
.reduce((s, f) => s + f.amount, 0);
|
|
|
|
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));
|
|
};
|
|
|
|
return (
|
|
<Stack gap="md">
|
|
{/* Header */}
|
|
<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>
|
|
</div>
|
|
<Badge size="lg" variant="light" color="blue">
|
|
Combined Total: ETB {totalCombined.toFixed(2)}
|
|
</Badge>
|
|
</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>
|
|
|
|
<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 }}>
|
|
<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>
|
|
</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>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<Text fz="sm" fw={700} c={cert.enabled ? 'blue.7' : 'dimmed'}>ETB {total.toFixed(2)}</Text>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<Switch
|
|
checked={cert.enabled}
|
|
onChange={() => handleToggleCert(cert.id)}
|
|
size="sm"
|
|
color="teal"
|
|
label={cert.enabled ? 'Active' : 'Disabled'}
|
|
/>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<ActionIcon variant="light" color="blue" size="md" onClick={() => setEditingCert(cert)}>
|
|
<IconEdit size={15} />
|
|
</ActionIcon>
|
|
</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>
|
|
|
|
{/* 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>
|
|
|
|
{/* 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}
|
|
/>
|
|
</Stack>
|
|
);
|
|
}
|