Files
emaui/apps/backoffice/src/app/features/license-register/pages/LicenseRegisterPage/index.tsx
Nati 220ed2d018 Merge branch 'dev' of github.com:Tria-plc/emaui into Refactor
Resolved by unifying on the lib/data AdvancedTable (PR #10) as the canonical
table component: kept its API plus teammate i18n/feature work, kept the
folder-per-table structure (index.tsx + columns.tsx + actions.tsx) across all
27 tables, removed the parallel lib/table implementation, and fixed
pre-existing compile breaks in ExamDetailPage/QuestionAssigner/RecordResultModal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 11:31:27 +00:00

193 lines
5.5 KiB
TypeScript

import { useState } from 'react';
import {
Button,
Card,
Container,
Group,
Modal,
Select,
Stack,
Text,
TextInput,
Textarea,
Title,
} from '@mantine/core';
import { IconSearch } from '@tabler/icons-react';
import { AdvancedTable, notify, useServerTable } 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';
import {
actionsFor,
licenseRegisterColumns,
type LifecycleAction,
} from './columns';
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.',
},
};
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, refetch } = useGetLicensesQuery(
search.trim() ? { search: search.trim() } : undefined,
);
const [target, setTarget] = useState<IssuedLicense | null>(null);
const items = data?.items ?? [];
const showDate = useDateDisplayer();
const localized = useLocalized();
const table = useServerTable();
const paged = table.paginate(items);
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}>
<AdvancedTable<IssuedLicense>
tableName="Licence register"
columns={licenseRegisterColumns(localized, showDate, { onStatus: setTarget })}
data={paged.rows}
itemCount={paged.itemCount}
pageIndex={paged.pageIndex}
onPageChange={table.setPageIndex}
pageSize={table.pageSize}
isLoading={isLoading}
refresh={refetch}
emptyText={
search ? 'No licences match that search.' : 'No licences issued yet.'
}
/>
</Card>
<LifecycleModal license={target} onClose={() => setTarget(null)} />
</Container>
);
}
export default LicenseRegisterPage;