Files
emaui/apps/backoffice/src/app/features/payment-config/pages/PaymentConfigPage.tsx
2026-06-20 09:44:41 +03:00

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>
);
}