Files
emaui/apps/backoffice/src/app/features/license-register/pages/LicenseRegisterPage.tsx
estifanos 31c52c02e0 feat: introduce useLocalized hook for bilingual value retrieval.(localization round 2)
- 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.
2026-08-12 11:31:20 +00:00

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;