mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
Merge branch 'WorkflowChange' of https://github.com/Tria-plc/emaui into estif-branch-1
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
import { Badge, Text } from '@mantine/core';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import type {
|
||||
MedicalCertificate,
|
||||
SeaServiceRecord,
|
||||
SeafarerProfileSummary,
|
||||
SeafarerRecordStatus,
|
||||
import {
|
||||
seaServiceDays,
|
||||
type MedicalCertificate,
|
||||
type SeaServiceRecord,
|
||||
type SeafarerProfileSummary,
|
||||
type SeafarerRecordStatus,
|
||||
} from '@ema-platform/api';
|
||||
|
||||
export function ownerName(profile?: SeafarerProfileSummary): string {
|
||||
@@ -133,6 +134,17 @@ export function seaServiceColumns(
|
||||
IMO {row.original.imoNumber}
|
||||
</Text>
|
||||
)}
|
||||
{(row.original.vesselType || row.original.flagState || row.original.grossTonnage) && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{[
|
||||
row.original.vesselType,
|
||||
row.original.flagState,
|
||||
row.original.grossTonnage ? `${row.original.grossTonnage} GT` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -151,6 +163,16 @@ export function seaServiceColumns(
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t('recordVerification.columns.days', 'Days'),
|
||||
label: t('recordVerification.columns.days', 'Days'),
|
||||
align: 'right',
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" fw={600}>
|
||||
{seaServiceDays(row.original.engagementDate, row.original.dischargeDate) ?? '—'}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
statusColumn<SeaServiceRecord>(t),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -11,18 +11,11 @@ import {
|
||||
Paper,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
Textarea,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAnchor,
|
||||
IconEye,
|
||||
IconInbox,
|
||||
IconPaperclip,
|
||||
IconStethoscope,
|
||||
} from '@tabler/icons-react';
|
||||
import { IconEye, IconInbox, IconPaperclip } from '@tabler/icons-react';
|
||||
import {
|
||||
AdvancedTable,
|
||||
notify,
|
||||
@@ -196,7 +189,14 @@ function AttachmentsModal({
|
||||
* freezes a record — sea service starts counting toward sea time, a medical
|
||||
* certificate starts satisfying the submission gate.
|
||||
*/
|
||||
export function MedicalVerificationPage() {
|
||||
export type VerificationKind = 'medical' | 'sea-service';
|
||||
|
||||
/**
|
||||
* One kind per page — the sidebar lists "Sea Service Verification" and
|
||||
* "Medical Verification" separately, so an officer lands on the queue they
|
||||
* came for rather than on a tab.
|
||||
*/
|
||||
export function MedicalVerificationPage({ kind = 'medical' }: { kind?: VerificationKind }) {
|
||||
const { t } = useTranslation();
|
||||
const [filter, setFilter] = useState<RecordQueueFilter>('SUBMITTED');
|
||||
const {
|
||||
@@ -372,74 +372,62 @@ export function MedicalVerificationPage() {
|
||||
[rulingSeaService, rule, verifySeaService, showDate, t],
|
||||
);
|
||||
|
||||
const isMedical = kind === 'medical';
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md">
|
||||
<Title order={3} mb={4}>
|
||||
{t('recordVerification.title', 'Record verification')}
|
||||
{isMedical
|
||||
? t('recordVerification.medicalTitle', 'Medical Certificate Verification')
|
||||
: t('recordVerification.seaServiceTitle', 'Sea Service Verification')}
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
{t(
|
||||
'recordVerification.subtitle',
|
||||
'Submitted sea-service records and medical certificates awaiting a ruling. Verified records are frozen; rejections return to the seafarer with your remark.',
|
||||
)}
|
||||
{isMedical
|
||||
? t(
|
||||
'recordVerification.medicalSubtitle',
|
||||
'Submitted medical certificates awaiting a ruling. Verified certificates are frozen; rejections return to the seafarer with your remark.',
|
||||
)
|
||||
: t(
|
||||
'recordVerification.seaServiceSubtitle',
|
||||
'Submitted sea-service records awaiting a ruling. Verified records are frozen and count toward sea time; rejections return to the seafarer with your remark.',
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<Tabs defaultValue="medical" keepMounted={false}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="medical" leftSection={<IconStethoscope size={16} />}>
|
||||
{t('recordVerification.tabs.medical', {
|
||||
count: pendingMedicalList.length,
|
||||
defaultValue: 'Medical ({{count}})',
|
||||
})}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="sea-service" leftSection={<IconAnchor size={16} />}>
|
||||
{t('recordVerification.tabs.seaService', {
|
||||
count: pendingSeaServiceList.length,
|
||||
defaultValue: 'Sea Service ({{count}})',
|
||||
})}
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="medical" pt="md">
|
||||
{statusFilter}
|
||||
<AdvancedTable
|
||||
columns={medicalTableColumns}
|
||||
data={pagedMedical}
|
||||
tableName={t('recordVerification.tabs.medical', {
|
||||
count: pendingMedicalList.length,
|
||||
defaultValue: 'Medical ({{count}})',
|
||||
})}
|
||||
itemCount={pendingMedicalList.length}
|
||||
pageIndex={medicalPage}
|
||||
onPageChange={setMedicalPage}
|
||||
pageSize={medicalPageSize}
|
||||
onPageSizeChange={handleMedicalPageSizeChange}
|
||||
refresh={refetchMedical}
|
||||
isLoading={loadingMedical || fetchingMedical}
|
||||
emptyText={emptyText}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="sea-service" pt="md">
|
||||
{statusFilter}
|
||||
<AdvancedTable
|
||||
columns={seaServiceTableColumns}
|
||||
data={pagedSeaService}
|
||||
tableName={t('recordVerification.tabs.seaService', {
|
||||
count: pendingSeaServiceList.length,
|
||||
defaultValue: 'Sea Service ({{count}})',
|
||||
})}
|
||||
itemCount={pendingSeaServiceList.length}
|
||||
pageIndex={seaServicePage}
|
||||
onPageChange={setSeaServicePage}
|
||||
pageSize={seaServicePageSize}
|
||||
onPageSizeChange={handleSeaServicePageSizeChange}
|
||||
refresh={refetchSeaService}
|
||||
isLoading={loadingSeaService || fetchingSeaService}
|
||||
emptyText={emptyText}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
{isMedical ? (
|
||||
<AdvancedTable
|
||||
columns={medicalTableColumns}
|
||||
data={pagedMedical}
|
||||
tableName={t('recordVerification.tabs.medical', {
|
||||
count: pendingMedicalList.length,
|
||||
defaultValue: 'Medical ({{count}})',
|
||||
})}
|
||||
itemCount={pendingMedicalList.length}
|
||||
pageIndex={medicalPage}
|
||||
onPageChange={setMedicalPage}
|
||||
pageSize={medicalPageSize}
|
||||
onPageSizeChange={handleMedicalPageSizeChange}
|
||||
refresh={refetchMedical}
|
||||
isLoading={loadingMedical || fetchingMedical}
|
||||
emptyText={t('recordVerification.emptyText', 'Nothing awaiting verification.')}
|
||||
/>
|
||||
) : (
|
||||
<AdvancedTable
|
||||
columns={seaServiceTableColumns}
|
||||
data={pagedSeaService}
|
||||
tableName={t('recordVerification.tabs.seaService', {
|
||||
count: pendingSeaServiceList.length,
|
||||
defaultValue: 'Sea Service ({{count}})',
|
||||
})}
|
||||
itemCount={pendingSeaServiceList.length}
|
||||
pageIndex={seaServicePage}
|
||||
onPageChange={setSeaServicePage}
|
||||
pageSize={seaServicePageSize}
|
||||
onPageSizeChange={handleSeaServicePageSizeChange}
|
||||
refresh={refetchSeaService}
|
||||
isLoading={loadingSeaService || fetchingSeaService}
|
||||
emptyText={t('recordVerification.emptyText', 'Nothing awaiting verification.')}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AttachmentsModal
|
||||
opened={Boolean(attachmentModal)}
|
||||
@@ -491,4 +479,6 @@ export function MedicalVerificationPage() {
|
||||
);
|
||||
}
|
||||
|
||||
export const SeaServiceVerificationPage = () => <MedicalVerificationPage kind="sea-service" />;
|
||||
|
||||
export default MedicalVerificationPage;
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Badge, Container, Group, Select, Text, TextInput, Title } from '@mantine/core';
|
||||
import { IconSearch } from '@tabler/icons-react';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import {
|
||||
SEAFARER_DOCUMENT_KIND_LABELS,
|
||||
SEAFARER_DOCUMENT_STATUS_COLORS,
|
||||
SEAFARER_DOCUMENT_STATUS_LABELS,
|
||||
useListSeafarerDocumentsQuery,
|
||||
type SeafarerDocumentKind,
|
||||
type SeafarerDocumentRow,
|
||||
type SeafarerDocumentStatus,
|
||||
} from '@ema-platform/api';
|
||||
import { AdvancedTable, type AdvancedColumn } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
/** Statuses an officer filters by — held and withdrawn requests are not work. */
|
||||
const STATUS_FILTERS = (
|
||||
['PAYMENT_PENDING', 'PAID', 'PAYMENT_CONFIRMED', 'SCHEDULED', 'ISSUED', 'REJECTED'] as SeafarerDocumentStatus[]
|
||||
).map((value) => ({ value, label: SEAFARER_DOCUMENT_STATUS_LABELS[value] }));
|
||||
|
||||
/**
|
||||
* The Seaman Book queue and the BTC queue — one page, keyed by kind. Requests
|
||||
* appear here once the seafarer registration that opened them is approved.
|
||||
*/
|
||||
export function SeafarerDocumentQueuePage({ kind }: { kind: SeafarerDocumentKind }) {
|
||||
const navigate = useNavigate();
|
||||
const showDate = useDateDisplayer();
|
||||
const [status, setStatus] = useState<SeafarerDocumentStatus | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch] = useDebouncedValue(search, 300);
|
||||
const [page, setPage] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(PAGE_SIZE);
|
||||
|
||||
const { data, isLoading, isFetching, refetch } = useListSeafarerDocumentsQuery({
|
||||
kind,
|
||||
status: status ?? undefined,
|
||||
search: debouncedSearch || undefined,
|
||||
take: pageSize,
|
||||
skip: page * pageSize,
|
||||
});
|
||||
|
||||
const columns: AdvancedColumn<SeafarerDocumentRow>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
header: 'Request №',
|
||||
accessorKey: 'requestNumber',
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<Text size="sm" ff="monospace">
|
||||
{row.original.requestNumber}
|
||||
</Text>
|
||||
{row.original.documentNumber && (
|
||||
<Text size="xs" c="teal" ff="monospace">
|
||||
{row.original.documentNumber}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Seafarer',
|
||||
accessorKey: 'applicant.name',
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<Text size="sm" fw={500}>
|
||||
{row.original.applicant?.name ?? '—'}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" ff="monospace">
|
||||
{row.original.applicant?.seafarerNumber ?? '—'}
|
||||
</Text>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Fee',
|
||||
accessorKey: 'feeAmount',
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm">
|
||||
{row.original.feeAmount !== null ? `${row.original.feeAmount} ${row.original.feeCurrency}` : '—'}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Released',
|
||||
accessorKey: 'submittedAt',
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm">{row.original.submittedAt ? showDate(row.original.submittedAt) : '—'}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Status',
|
||||
accessorKey: 'status',
|
||||
cell: ({ row }) => (
|
||||
<Badge size="sm" variant="light" color={SEAFARER_DOCUMENT_STATUS_COLORS[row.original.status]}>
|
||||
{SEAFARER_DOCUMENT_STATUS_LABELS[row.original.status]}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
],
|
||||
[showDate],
|
||||
);
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md">
|
||||
<Title order={3} mb={4}>
|
||||
{SEAFARER_DOCUMENT_KIND_LABELS[kind]} Queue
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
Requests released by an approved seafarer registration: confirm payment, schedule the
|
||||
collection date, then issue.
|
||||
</Text>
|
||||
<Group mb="md" gap="sm">
|
||||
<TextInput
|
||||
placeholder="Search number, name or seafarer №…"
|
||||
leftSection={<IconSearch size={14} />}
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.currentTarget.value);
|
||||
setPage(0);
|
||||
}}
|
||||
w={280}
|
||||
/>
|
||||
<Select
|
||||
placeholder="All statuses"
|
||||
data={STATUS_FILTERS}
|
||||
value={status}
|
||||
onChange={(v) => {
|
||||
setStatus(v as SeafarerDocumentStatus | null);
|
||||
setPage(0);
|
||||
}}
|
||||
clearable
|
||||
w={220}
|
||||
/>
|
||||
</Group>
|
||||
<AdvancedTable
|
||||
columns={columns}
|
||||
data={data?.items ?? []}
|
||||
tableName={`${SEAFARER_DOCUMENT_KIND_LABELS[kind]} requests`}
|
||||
itemCount={data?.total ?? 0}
|
||||
pageIndex={page}
|
||||
onPageChange={setPage}
|
||||
pageSize={pageSize}
|
||||
onPageSizeChange={(size) => {
|
||||
setPageSize(size);
|
||||
setPage(0);
|
||||
}}
|
||||
refresh={refetch}
|
||||
isLoading={isLoading || isFetching}
|
||||
emptyText="No requests match."
|
||||
onRowClick={(row) => navigate(`/seafarer-documents/${row.id}`)}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export const SeamanBookQueuePage = () => <SeafarerDocumentQueuePage kind="SEAMAN_BOOK" />;
|
||||
export const BtcQueuePage = () => <SeafarerDocumentQueuePage kind="BTC_BASIC_TRAINING" />;
|
||||
@@ -0,0 +1,276 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Center,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Paper,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Textarea,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconAlertTriangle, IconArrowLeft, IconCheck, IconDownload } from '@tabler/icons-react';
|
||||
import {
|
||||
SEAFARER_DOCUMENT_KIND_LABELS,
|
||||
SEAFARER_DOCUMENT_STATUS_COLORS,
|
||||
SEAFARER_DOCUMENT_STATUS_LABELS,
|
||||
extractErrorMessage,
|
||||
useConfirmSeafarerDocumentPaymentMutation,
|
||||
useGetSeafarerDocumentReviewQuery,
|
||||
useIssueSeafarerDocumentMutation,
|
||||
useLazyGetSeafarerDocumentReviewDownloadQuery,
|
||||
useRejectSeafarerDocumentMutation,
|
||||
useScheduleSeafarerDocumentMutation,
|
||||
} from '@ema-platform/api';
|
||||
import { AmharicDatePicker, notify } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||
|
||||
const QUEUE_PATH = { SEAMAN_BOOK: '/seaman-book-queue', BTC_BASIC_TRAINING: '/btc-queue' } as const;
|
||||
|
||||
function Row({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<Table.Tr>
|
||||
<Table.Td w="40%">
|
||||
<Text size="xs" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" component="div">
|
||||
{value ?? '—'}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
}
|
||||
|
||||
/** One Seaman Book / BTC request: payment → collection date → issue, or reject. */
|
||||
export function SeafarerDocumentReviewPage() {
|
||||
const { id = '' } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const showDate = useDateDisplayer();
|
||||
const { data, isLoading, error } = useGetSeafarerDocumentReviewQuery(id, { skip: !id });
|
||||
|
||||
const [confirmPayment, { isLoading: confirming }] = useConfirmSeafarerDocumentPaymentMutation();
|
||||
const [schedule, { isLoading: scheduling }] = useScheduleSeafarerDocumentMutation();
|
||||
const [issue, { isLoading: issuing }] = useIssueSeafarerDocumentMutation();
|
||||
const [reject, { isLoading: rejecting }] = useRejectSeafarerDocumentMutation();
|
||||
const [getDownload, { isFetching: downloading }] = useLazyGetSeafarerDocumentReviewDownloadQuery();
|
||||
|
||||
const [scheduleOpen, setScheduleOpen] = useState(false);
|
||||
const [pickupDate, setPickupDate] = useState('');
|
||||
const [rejectOpen, setRejectOpen] = useState(false);
|
||||
const [reason, setReason] = useState('');
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center h={300}>
|
||||
<Loader />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
if (error || !data) {
|
||||
return (
|
||||
<Container size="md" py="xl">
|
||||
<Alert color="red" icon={<IconAlertTriangle size={16} />}>
|
||||
{extractErrorMessage(error, 'Could not load this request.')}
|
||||
</Alert>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
const { document, applicant, payment } = data;
|
||||
const kindLabel = SEAFARER_DOCUMENT_KIND_LABELS[document.kind];
|
||||
const terminal = ['ISSUED', 'REJECTED', 'CANCELLED'].includes(document.status);
|
||||
|
||||
async function run(action: () => Promise<unknown>, done: string) {
|
||||
try {
|
||||
await action();
|
||||
notify.success(done);
|
||||
setScheduleOpen(false);
|
||||
setRejectOpen(false);
|
||||
setReason('');
|
||||
} catch (err) {
|
||||
notify.error(extractErrorMessage(err, 'Could not record the action'));
|
||||
}
|
||||
}
|
||||
|
||||
async function download() {
|
||||
try {
|
||||
const { url } = await getDownload(id).unwrap();
|
||||
window.open(url, '_blank', 'noopener');
|
||||
} catch (err) {
|
||||
notify.error(extractErrorMessage(err, 'Could not fetch the PDF'));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Container size="lg" py="md">
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
leftSection={<IconArrowLeft size={14} />}
|
||||
onClick={() => navigate(QUEUE_PATH[document.kind])}
|
||||
mb="xs"
|
||||
>
|
||||
Back to queue
|
||||
</Button>
|
||||
<Group justify="space-between" align="flex-start" mb="md">
|
||||
<div>
|
||||
<Title order={3}>
|
||||
{kindLabel} — {applicant?.name ?? '—'}
|
||||
</Title>
|
||||
<Group gap="xs" mt={4}>
|
||||
<Text size="sm" c="dimmed" ff="monospace">
|
||||
{document.requestNumber}
|
||||
</Text>
|
||||
<Badge size="sm" variant="light" color={SEAFARER_DOCUMENT_STATUS_COLORS[document.status]}>
|
||||
{SEAFARER_DOCUMENT_STATUS_LABELS[document.status]}
|
||||
</Badge>
|
||||
{document.documentNumber && (
|
||||
<Badge size="sm" color="teal" leftSection={<IconCheck size={10} />}>
|
||||
{document.documentNumber}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
{(document.status === 'PAYMENT_PENDING' || document.status === 'PAID') && (
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_APPLICATION]} hideOnly>
|
||||
<Button loading={confirming} onClick={() => run(() => confirmPayment(id).unwrap(), 'Payment confirmed')}>
|
||||
Confirm payment
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)}
|
||||
{document.status === 'PAYMENT_CONFIRMED' && (
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_APPLICATION]} hideOnly>
|
||||
<Button onClick={() => setScheduleOpen(true)}>Schedule pickup</Button>
|
||||
</RequirePermission>
|
||||
)}
|
||||
{(document.status === 'SCHEDULED' || document.status === 'PAYMENT_CONFIRMED') && (
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_APPLICATION]} hideOnly>
|
||||
<Button color="teal" loading={issuing} onClick={() => run(() => issue(id).unwrap(), `${kindLabel} issued`)}>
|
||||
Issue
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)}
|
||||
{document.status === 'ISSUED' && (
|
||||
<Button variant="light" leftSection={<IconDownload size={14} />} loading={downloading} onClick={download}>
|
||||
Download PDF
|
||||
</Button>
|
||||
)}
|
||||
{!terminal && (
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.REJECT_APPLICATION]} hideOnly>
|
||||
<Button color="red" variant="light" onClick={() => setRejectOpen(true)}>
|
||||
Reject
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{document.status === 'REJECTED' && (
|
||||
<Alert color="red" icon={<IconAlertTriangle size={16} />} title="Rejected" mb="md">
|
||||
{document.rejectionReason}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Paper withBorder p="lg" radius="md">
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<Text fw={600} size="sm" mb={4}>
|
||||
Seafarer
|
||||
</Text>
|
||||
<Table withTableBorder withColumnBorders>
|
||||
<Table.Tbody>
|
||||
<Row label="Name" value={applicant?.name} />
|
||||
<Row label="Seafarer №" value={applicant?.seafarerNumber} />
|
||||
<Row
|
||||
label="Registration"
|
||||
value={
|
||||
applicant?.registrationId ? (
|
||||
<Link to={`/seafarer-registrations/${applicant.registrationId}`}>
|
||||
{applicant.registrationNumber}
|
||||
</Link>
|
||||
) : (
|
||||
applicant?.registrationNumber
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</div>
|
||||
<div>
|
||||
<Text fw={600} size="sm" mb={4}>
|
||||
Payment
|
||||
</Text>
|
||||
<Table withTableBorder withColumnBorders>
|
||||
<Table.Tbody>
|
||||
<Row label="Fee" value={document.feeAmount !== null ? `${document.feeAmount} ${document.feeCurrency}` : null} />
|
||||
<Row label="Released to payment" value={document.submittedAt ? showDate(document.submittedAt) : null} />
|
||||
<Row label="Paid" value={document.paidAt ? showDate(document.paidAt) : null} />
|
||||
<Row label="Provider" value={payment?.provider} />
|
||||
<Row label="Reference" value={document.paymentReference ?? payment?.providerRef} />
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</div>
|
||||
<div>
|
||||
<Text fw={600} size="sm" mb={4}>
|
||||
Issuance
|
||||
</Text>
|
||||
<Table withTableBorder withColumnBorders>
|
||||
<Table.Tbody>
|
||||
<Row label="Pickup date" value={document.scheduledIssuanceDate ? showDate(document.scheduledIssuanceDate) : null} />
|
||||
<Row label="Document №" value={document.documentNumber} />
|
||||
<Row label="Issued" value={document.issueDate ? showDate(document.issueDate) : null} />
|
||||
<Row label="Valid until" value={document.expiryDate ? showDate(document.expiryDate) : null} />
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</div>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Modal opened={scheduleOpen} onClose={() => setScheduleOpen(false)} title="Schedule pickup" centered>
|
||||
<Stack>
|
||||
<AmharicDatePicker label="Pickup date" dateFormat="date" value={pickupDate} onChange={setPickupDate} required />
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setScheduleOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!pickupDate}
|
||||
loading={scheduling}
|
||||
onClick={() => run(() => schedule({ id, scheduledDate: pickupDate }).unwrap(), 'Pickup scheduled')}
|
||||
>
|
||||
Schedule
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<Modal opened={rejectOpen} onClose={() => setRejectOpen(false)} title={`Reject ${kindLabel}`} centered>
|
||||
<Stack>
|
||||
<Textarea label="Reason (shown to the seafarer)" required minRows={3} value={reason} onChange={(e) => setReason(e.currentTarget.value)} data-autofocus />
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setRejectOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="red" disabled={reason.trim().length < 3} loading={rejecting} onClick={() => run(() => reject({ id, reason: reason.trim() }).unwrap(), 'Request rejected')}>
|
||||
Confirm
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default SeafarerDocumentReviewPage;
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
Textarea,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconAlertTriangle, IconArrowLeft, IconCheck, IconInfoCircle } from '@tabler/icons-react';
|
||||
import { IconAlertTriangle, IconArrowLeft, IconCheck } from '@tabler/icons-react';
|
||||
import {
|
||||
SEAFARER_REGISTRATION_DOCUMENTS,
|
||||
SEAFARER_REGISTRATION_FIELD_LABELS,
|
||||
@@ -27,14 +27,12 @@ import {
|
||||
displaySeafarerAnswer,
|
||||
extractErrorMessage,
|
||||
useApproveSeafarerRegistrationMutation,
|
||||
useClaimSeafarerRegistrationMutation,
|
||||
useGetSeafarerRegistrationReviewQuery,
|
||||
useRejectSeafarerRegistrationMutation,
|
||||
useRequestSeafarerRegistrationChangesMutation,
|
||||
} from '@ema-platform/api';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||
import { useAppSelector } from '../../../store/hooks';
|
||||
import { applicantName } from './SeafarerRegistrationQueuePage';
|
||||
|
||||
type Decision = 'approve' | 'reject' | 'changes';
|
||||
@@ -49,10 +47,8 @@ const DECISION_COPY: Record<Decision, { title: string; label: string; color: str
|
||||
export function SeafarerRegistrationReviewPage() {
|
||||
const { id = '' } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const me = useAppSelector((state) => state.auth.user);
|
||||
const { data, isLoading, error } = useGetSeafarerRegistrationReviewQuery(id, { skip: !id });
|
||||
|
||||
const [claim, { isLoading: claiming }] = useClaimSeafarerRegistrationMutation();
|
||||
const [approve, { isLoading: approving }] = useApproveSeafarerRegistrationMutation();
|
||||
const [reject, { isLoading: rejecting }] = useRejectSeafarerRegistrationMutation();
|
||||
const [requestChanges, { isLoading: requesting }] = useRequestSeafarerRegistrationChangesMutation();
|
||||
@@ -78,8 +74,8 @@ export function SeafarerRegistrationReviewPage() {
|
||||
}
|
||||
|
||||
const { registration, attachments } = data;
|
||||
const mine = !registration.assignedOfficerId || registration.assignedOfficerId === me?.id;
|
||||
const canDecide = registration.status === 'UNDER_REVIEW' && mine;
|
||||
// Decided straight off the queue — no claim step.
|
||||
const canDecide = registration.status === 'SUBMITTED';
|
||||
const busy = approving || rejecting || requesting;
|
||||
|
||||
async function run(action: () => Promise<unknown>, done: string) {
|
||||
@@ -131,13 +127,6 @@ export function SeafarerRegistrationReviewPage() {
|
||||
</Group>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
{registration.status === 'SUBMITTED' && (
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.CLAIM_APPLICATION]} hideOnly>
|
||||
<Button loading={claiming} onClick={() => run(() => claim(id).unwrap(), 'Claimed — it is yours to review.')}>
|
||||
Claim
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)}
|
||||
{canDecide && (
|
||||
<>
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.REQUEST_ADJUSTMENT]} hideOnly>
|
||||
@@ -160,11 +149,6 @@ export function SeafarerRegistrationReviewPage() {
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{registration.status === 'UNDER_REVIEW' && !mine && (
|
||||
<Alert color="gray" icon={<IconInfoCircle size={16} />} mb="md">
|
||||
Assigned to another officer — only they can decide it.
|
||||
</Alert>
|
||||
)}
|
||||
{registration.status === 'RESUBMIT_REQUIRED' && (
|
||||
<Alert color="orange" icon={<IconAlertTriangle size={16} />} title="Awaiting the applicant's corrections" mb="md">
|
||||
{registration.reviewRemark}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { Container } from '@mantine/core';
|
||||
import { FeatureUnavailable } from '@ema-platform/ui';
|
||||
|
||||
/**
|
||||
* Placeholder until this feature has a backend.
|
||||
*
|
||||
* This page previously rendered hardcoded sample records, which were
|
||||
* indistinguishable from real ones.
|
||||
*/
|
||||
export function SeamanBookQueuePage() {
|
||||
return (
|
||||
<Container size="lg" py="xl">
|
||||
<FeatureUnavailable
|
||||
title="Seaman Book queue"
|
||||
description="Seaman Book applications are not connected to the backend yet."
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default SeamanBookQueuePage;
|
||||
@@ -92,7 +92,8 @@ export const am: Translations = {
|
||||
applications: "ማመልከቻዎች",
|
||||
paymentConfig: "የክፍያ ውቅረት",
|
||||
analytics: "ትንታኔ",
|
||||
medicalVerification: "የሕክምና እና የባህር አገልግሎት ማረጋገጫ",
|
||||
seaServiceVerification: "የባህር አገልግሎት ማረጋገጫ",
|
||||
medicalVerification: "የሕክምና ማረጋገጫ",
|
||||
locations: "አካባቢዎች",
|
||||
configuration: "ውቅረት",
|
||||
profile: "መገለጫ",
|
||||
|
||||
@@ -90,7 +90,8 @@ export const en = {
|
||||
applications: 'Applications',
|
||||
paymentConfig: 'Payment Config',
|
||||
analytics: 'Analytics',
|
||||
medicalVerification: 'Medical and Sea Service Verification',
|
||||
seaServiceVerification: 'Sea Service Verification',
|
||||
medicalVerification: 'Medical Verification',
|
||||
locations: 'Locations',
|
||||
configuration: 'Configuration',
|
||||
profile: 'Profile',
|
||||
|
||||
@@ -98,10 +98,11 @@ export const NAV_SECTIONS: NavSection[] = [
|
||||
{ to: '/seafarer-registrations', label: 'nav.seafarerRegistrationQueue', icon: IconId, permissions: APPLICATION_QUEUE },
|
||||
{ to: '/licence-review/type/CERTIFICATE_OF_COMPETENCY', label: 'nav.cocQueue', icon: IconShieldCheck, permissions: APPLICATION_QUEUE },
|
||||
{ to: '/licence-review/type/CERTIFICATE_OF_PROFICIENCY', label: 'nav.copQueue', icon: IconShieldCheck, permissions: APPLICATION_QUEUE },
|
||||
{ to: '/licence-review/type/SEAMAN_BOOK', label: 'nav.seamanBookQueue', icon: IconBook2, permissions: APPLICATION_QUEUE },
|
||||
{ to: '/licence-review/type/BTC_BASIC_TRAINING', label: 'nav.btcQueue', icon: IconShieldCheck, permissions: APPLICATION_QUEUE },
|
||||
{ to: '/seaman-book-queue', label: 'nav.seamanBookQueue', icon: IconBook2, permissions: APPLICATION_QUEUE },
|
||||
{ to: '/btc-queue', label: 'nav.btcQueue', icon: IconShieldCheck, permissions: APPLICATION_QUEUE },
|
||||
{ to: '/licence-review/type/ENDORSEMENT_COC', label: 'nav.endorsementCocQueue', icon: IconRubberStamp, permissions: APPLICATION_QUEUE },
|
||||
{ to: '/licence-review/type/ENDORSEMENT_GOC', label: 'nav.endorsementGocQueue', icon: IconRubberStamp, permissions: APPLICATION_QUEUE },
|
||||
{ to: '/sea-service-verification', label: 'nav.seaServiceVerification', icon: IconAnchor, permissions: [P.VERIFY_SEAFARER_RECORDS] },
|
||||
{ to: '/medical-verification', label: 'nav.medicalVerification', icon: IconHeart, permissions: [P.VERIFY_SEAFARER_RECORDS] },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -22,12 +22,16 @@ import { ProfilePage } from '../features/profile/pages/ProfilePage';
|
||||
import { ConfigurationPage } from '../features/configuration/pages/ConfigurationPage';
|
||||
import { AnalyticsPage } from '../features/analytics/pages/AnalyticsPage';
|
||||
import { ApplicationReviewPage } from '../features/applications/pages/ApplicationReviewPage';
|
||||
import { MedicalVerificationPage } from '../features/medical-verification/pages/MedicalVerificationPage';
|
||||
import {
|
||||
MedicalVerificationPage,
|
||||
SeaServiceVerificationPage,
|
||||
} from '../features/medical-verification/pages/MedicalVerificationPage';
|
||||
import { PaymentConfigPage } from '../features/payment-config/pages/PaymentConfigPage';
|
||||
import { SeafarerRegistryPage } from '../features/seafarer-registry/pages/SeafarerRegistryPage';
|
||||
import { SeafarerRegistrationQueuePage } from '../features/seafarer-registration-review/pages/SeafarerRegistrationQueuePage';
|
||||
import { SeafarerRegistrationReviewPage } from '../features/seafarer-registration-review/pages/SeafarerRegistrationReviewPage';
|
||||
import { SeamanBookQueuePage } from '../features/seaman-book-queue/pages/SeamanBookQueuePage';
|
||||
import { BtcQueuePage, SeamanBookQueuePage } from '../features/seafarer-document-review/pages/SeafarerDocumentQueuePage';
|
||||
import { SeafarerDocumentReviewPage } from '../features/seafarer-document-review/pages/SeafarerDocumentReviewPage';
|
||||
import { QuestionPage } from '../features/question/pages/QuestionPage';
|
||||
import { ExamPage } from '../features/exam/pages/ExamPage';
|
||||
import { ExamDetailPage } from '../features/exam/pages/ExamDetailPage';
|
||||
@@ -84,13 +88,19 @@ const router = createBrowserRouter([
|
||||
{ path: 'endorsement-queue', element: <Navigate to="/licence-review/type/ENDORSEMENT_COC" replace /> },
|
||||
{ path: 'endorsement-queue/:id', element: <Navigate to="/licence-review" replace /> },
|
||||
{ path: 'medical-verification', element: guard([P.VERIFY_SEAFARER_RECORDS], <MedicalVerificationPage />) },
|
||||
{ path: 'sea-service-verification', element: guard([P.VERIFY_SEAFARER_RECORDS], <SeaServiceVerificationPage />) },
|
||||
{ path: 'payment-config', element: guard([P.VIEW_PAYMENTS], <PaymentConfigPage />) },
|
||||
{ path: 'seafarer-registry', element: guard([P.VIEW_SEAFARER_REGISTRY], <SeafarerRegistryPage />) },
|
||||
// Seafarer registration is not a licence: own queue, own review.
|
||||
{ path: 'seafarer-registrations', element: guard(APPLICATION_QUEUE, <SeafarerRegistrationQueuePage />) },
|
||||
{ path: 'seafarer-registrations/:id', element: guard(APPLICATION_QUEUE, <SeafarerRegistrationReviewPage />) },
|
||||
{ path: 'licence-review/type/SEAFARER_REGISTRATION', element: <Navigate to="/seafarer-registrations" replace /> },
|
||||
// Seaman Book and BTC are not licences: own queues, own review.
|
||||
{ path: 'seaman-book-queue', element: guard(APPLICATION_QUEUE, <SeamanBookQueuePage />) },
|
||||
{ path: 'btc-queue', element: guard(APPLICATION_QUEUE, <BtcQueuePage />) },
|
||||
{ path: 'seafarer-documents/:id', element: guard(APPLICATION_QUEUE, <SeafarerDocumentReviewPage />) },
|
||||
{ path: 'licence-review/type/SEAMAN_BOOK', element: <Navigate to="/seaman-book-queue" replace /> },
|
||||
{ path: 'licence-review/type/BTC_BASIC_TRAINING', element: <Navigate to="/btc-queue" replace /> },
|
||||
{ path: 'questions', element: guard([P.APPROVE_QUESTION, P.AUTHOR_QUESTION], <QuestionPage />) },
|
||||
{ path: 'exams', element: guard([P.MANAGE_EXAMS, P.RECORD_EXAM_ATTENDANCE, P.MANAGE_EXAM_INCIDENTS, P.PUBLISH_EXAM_RESULT], <ExamPage />) },
|
||||
{ path: 'exams/:id', element: guard([P.MANAGE_EXAMS, P.RECORD_EXAM_ATTENDANCE, P.MANAGE_EXAM_INCIDENTS, P.PUBLISH_EXAM_RESULT], <ExamDetailPage />) },
|
||||
|
||||
Reference in New Issue
Block a user