mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-08 22:58:18 +00:00
- Added useLocalized hook to provide a stable function for retrieving bilingual values based on the current language. - Updated various components across the portal and backoffice to utilize the new useLocalized hook for consistent bilingual label rendering. - Refactored localized function in licensing.helpers to handle empty Amharic strings correctly. - Enhanced localization handling in LicenseApplicationPage, ProfilePage, and other components to ensure proper language switching.
277 lines
8.1 KiB
TypeScript
277 lines
8.1 KiB
TypeScript
import { useState } from 'react';
|
|
import {
|
|
Badge,
|
|
Button,
|
|
Card,
|
|
Center,
|
|
Container,
|
|
Group,
|
|
Loader,
|
|
Modal,
|
|
Select,
|
|
Stack,
|
|
Table,
|
|
Text,
|
|
TextInput,
|
|
Textarea,
|
|
Title,
|
|
Tooltip,
|
|
} from '@mantine/core';
|
|
import { IconSearch, IconShieldCog } from '@tabler/icons-react';
|
|
import { notify } from '@ema-platform/ui';
|
|
import { useDateDisplayer } from '@ema-platform/shared';
|
|
import {
|
|
extractErrorMessage,
|
|
useLocalized,
|
|
useGetLicensesQuery,
|
|
useReinstateLicenseMutation,
|
|
useRevokeLicenseMutation,
|
|
useSuspendLicenseMutation,
|
|
} from '@ema-platform/api';
|
|
import type { IssuedLicense } from '@ema-platform/api';
|
|
|
|
const LICENSE_STATUS_COLORS: Record<string, string> = {
|
|
ACTIVE: 'green',
|
|
EXPIRED: 'yellow',
|
|
SUSPENDED: 'orange',
|
|
CANCELLED: 'red',
|
|
SUPERSEDED: 'gray',
|
|
};
|
|
|
|
type LifecycleAction = 'suspend' | 'revoke' | 'reinstate';
|
|
|
|
const ACTIONS: Record<
|
|
LifecycleAction,
|
|
{ label: string; color: string; confirm: string }
|
|
> = {
|
|
suspend: {
|
|
label: 'Suspend',
|
|
color: 'orange',
|
|
confirm: 'Temporarily withdraws the right to trade. Reversible.',
|
|
},
|
|
revoke: {
|
|
label: 'Revoke',
|
|
color: 'red',
|
|
confirm: 'Permanently cancels the licence. This cannot be undone.',
|
|
},
|
|
reinstate: {
|
|
label: 'Reinstate',
|
|
color: 'green',
|
|
confirm: 'Restores a suspended licence to active.',
|
|
},
|
|
};
|
|
|
|
/** Which lifecycle actions make sense from each current status. */
|
|
function actionsFor(license: IssuedLicense): LifecycleAction[] {
|
|
switch (license.status) {
|
|
case 'ACTIVE':
|
|
case 'EXPIRED':
|
|
return ['suspend', 'revoke'];
|
|
case 'SUSPENDED':
|
|
return ['reinstate', 'revoke'];
|
|
default:
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function LifecycleModal({
|
|
license,
|
|
onClose,
|
|
}: {
|
|
license: IssuedLicense | null;
|
|
onClose: () => void;
|
|
}) {
|
|
const [action, setAction] = useState<LifecycleAction | null>(null);
|
|
const [reason, setReason] = useState('');
|
|
const [suspend, { isLoading: suspending }] = useSuspendLicenseMutation();
|
|
const [revoke, { isLoading: revoking }] = useRevokeLicenseMutation();
|
|
const [reinstate, { isLoading: reinstating }] = useReinstateLicenseMutation();
|
|
|
|
const available = license ? actionsFor(license) : [];
|
|
|
|
const submit = async () => {
|
|
if (!license || !action) return;
|
|
const run =
|
|
action === 'suspend' ? suspend : action === 'revoke' ? revoke : reinstate;
|
|
try {
|
|
await run({ id: license.id, reason }).unwrap();
|
|
notify.success(`Licence ${ACTIONS[action].label.toLowerCase()}d`);
|
|
onClose();
|
|
setAction(null);
|
|
setReason('');
|
|
} catch (error) {
|
|
notify.error(extractErrorMessage(error, 'Could not update the licence'));
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Modal
|
|
opened={Boolean(license)}
|
|
onClose={onClose}
|
|
title={`Licence ${license?.certificateNumber ?? ''}`}
|
|
centered
|
|
>
|
|
<Stack>
|
|
<Text size="sm" c="dimmed">
|
|
{license?.companyName} — currently {license?.status}. The reason is
|
|
recorded verbatim on the licence and in the audit trail, and the
|
|
holder is able to see it.
|
|
</Text>
|
|
<Select
|
|
label="Action"
|
|
required
|
|
data={available.map((a) => ({ value: a, label: ACTIONS[a].label }))}
|
|
value={action}
|
|
onChange={(value) => setAction(value as LifecycleAction | null)}
|
|
/>
|
|
{action && (
|
|
<Text size="sm" c={ACTIONS[action].color}>
|
|
{ACTIONS[action].confirm}
|
|
</Text>
|
|
)}
|
|
<Textarea
|
|
label="Reason"
|
|
required
|
|
minRows={2}
|
|
value={reason}
|
|
onChange={(e) => setReason(e.target.value)}
|
|
/>
|
|
<Group justify="flex-end">
|
|
<Button variant="default" onClick={onClose}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
color={action ? ACTIONS[action].color : undefined}
|
|
disabled={!action || reason.trim().length < 3}
|
|
loading={suspending || revoking || reinstating}
|
|
onClick={submit}
|
|
>
|
|
Confirm
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The licence register (US-LOG-017/018): every issued licence with its
|
|
* standing, and the suspend/revoke/reinstate actions that were previously
|
|
* API-only. Queue work stays in the licence queue; this is enforcement.
|
|
*/
|
|
export function LicenseRegisterPage() {
|
|
const [search, setSearch] = useState('');
|
|
const { data, isLoading } = useGetLicensesQuery(
|
|
search.trim() ? { search: search.trim() } : undefined,
|
|
);
|
|
const [target, setTarget] = useState<IssuedLicense | null>(null);
|
|
|
|
const items = data?.items ?? [];
|
|
const showDate = useDateDisplayer();
|
|
const localized = useLocalized();
|
|
|
|
return (
|
|
<Container size="xl" py="md">
|
|
<Group justify="space-between" mb="md">
|
|
<div>
|
|
<Title order={3}>Licence register</Title>
|
|
<Text size="sm" c="dimmed">
|
|
{data?.total ?? 0} issued licence{(data?.total ?? 0) === 1 ? '' : 's'}
|
|
</Text>
|
|
</div>
|
|
<TextInput
|
|
placeholder="Certificate № or company"
|
|
leftSection={<IconSearch size={14} />}
|
|
value={search}
|
|
onChange={(e) => setSearch(e.currentTarget.value)}
|
|
w={280}
|
|
/>
|
|
</Group>
|
|
|
|
<Card withBorder padding={0}>
|
|
{isLoading ? (
|
|
<Center h={200}>
|
|
<Loader />
|
|
</Center>
|
|
) : items.length === 0 ? (
|
|
<Center h={160}>
|
|
<Text size="sm" c="dimmed">
|
|
{search ? 'No licences match that search.' : 'No licences issued yet.'}
|
|
</Text>
|
|
</Center>
|
|
) : (
|
|
<Table highlightOnHover>
|
|
<Table.Thead>
|
|
<Table.Tr>
|
|
<Table.Th>Certificate №</Table.Th>
|
|
<Table.Th>Type</Table.Th>
|
|
<Table.Th>Holder</Table.Th>
|
|
<Table.Th>Issued</Table.Th>
|
|
<Table.Th>Expires</Table.Th>
|
|
<Table.Th>Status</Table.Th>
|
|
<Table.Th />
|
|
</Table.Tr>
|
|
</Table.Thead>
|
|
<Table.Tbody>
|
|
{items.map((license) => (
|
|
<Table.Tr key={license.id}>
|
|
<Table.Td>
|
|
<Text size="sm" ff="monospace" fw={600}>
|
|
{license.certificateNumber}
|
|
</Text>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<Text size="sm">
|
|
{localized(license.licenseType?.name)}
|
|
</Text>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<Text size="sm">{license.companyName ?? '—'}</Text>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<Text size="sm" c="dimmed">
|
|
{showDate(license.issueDate)}
|
|
</Text>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<Text size="sm" c="dimmed">
|
|
{showDate(license.expiryDate)}
|
|
</Text>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<Badge
|
|
size="sm"
|
|
variant="light"
|
|
color={LICENSE_STATUS_COLORS[license.status] ?? 'gray'}
|
|
>
|
|
{license.status}
|
|
</Badge>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
{actionsFor(license).length > 0 && (
|
|
<Tooltip label="Suspend / revoke / reinstate">
|
|
<Button
|
|
size="compact-xs"
|
|
variant="subtle"
|
|
leftSection={<IconShieldCog size={14} />}
|
|
onClick={() => setTarget(license)}
|
|
>
|
|
Status
|
|
</Button>
|
|
</Tooltip>
|
|
)}
|
|
</Table.Td>
|
|
</Table.Tr>
|
|
))}
|
|
</Table.Tbody>
|
|
</Table>
|
|
)}
|
|
</Card>
|
|
|
|
<LifecycleModal license={target} onClose={() => setTarget(null)} />
|
|
</Container>
|
|
);
|
|
}
|
|
|
|
export default LicenseRegisterPage;
|