mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
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>
193 lines
5.5 KiB
TypeScript
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;
|