mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 13:02:50 +00:00
469 lines
15 KiB
TypeScript
469 lines
15 KiB
TypeScript
import { useCallback, useMemo, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import {Badge, Button, Center, Container, Group, Loader, Modal, Paper, SegmentedControl, Stack, Text, Textarea} from '@mantine/core';
|
|
import { IconEye, IconInbox, IconPaperclip } from '@tabler/icons-react';
|
|
import { AdvancedTable, notify, PageHeader, PdfPreviewModal, type AdvancedColumn } from '@ema-platform/ui';
|
|
import { useDateDisplayer } from '@ema-platform/shared';
|
|
import {
|
|
extractErrorMessage,
|
|
useGetAttachmentsQuery,
|
|
useGetPendingMedicalQuery,
|
|
useGetPendingSeaServiceQuery,
|
|
useVerifyMedicalCertificateMutation,
|
|
useVerifySeaServiceRecordMutation,
|
|
} from '@ema-platform/api';
|
|
import type {
|
|
MedicalCertificate,
|
|
RecordQueueFilter,
|
|
SeaServiceRecord,
|
|
} from '@ema-platform/api';
|
|
import { medicalColumns, seaServiceColumns, ownerName } from './columns';
|
|
import { medicalActionsColumn, seaServiceActionsColumn } from './actions';
|
|
|
|
const PAGE_SIZE = 10;
|
|
|
|
/** Reject dialog — the remark is what the seafarer sees and must act on. */
|
|
function RejectModal({
|
|
title,
|
|
opened,
|
|
onClose,
|
|
onConfirm,
|
|
loading,
|
|
}: {
|
|
title: string;
|
|
opened: boolean;
|
|
onClose: () => void;
|
|
onConfirm: (remark: string) => void;
|
|
loading: boolean;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const [remark, setRemark] = useState('');
|
|
return (
|
|
<Modal opened={opened} onClose={onClose} title={title} centered>
|
|
<Stack>
|
|
<Textarea
|
|
label={t('recordVerification.rejectReasonLabel', 'What must the seafarer fix?')}
|
|
required
|
|
minRows={2}
|
|
value={remark}
|
|
onChange={(e) => setRemark(e.target.value)}
|
|
/>
|
|
<Group justify="flex-end">
|
|
<Button variant="default" onClick={onClose}>
|
|
{t('recordVerification.cancel', 'Cancel')}
|
|
</Button>
|
|
<Button
|
|
color="red"
|
|
disabled={remark.trim().length < 3}
|
|
loading={loading}
|
|
onClick={() => {
|
|
onConfirm(remark.trim());
|
|
setRemark('');
|
|
}}
|
|
>
|
|
{t('recordVerification.reject', 'Reject')}
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
/** Attachments / Evidence Modal for viewing uploaded files for a record */
|
|
function AttachmentsModal({
|
|
opened,
|
|
ownerType,
|
|
ownerId,
|
|
title,
|
|
onClose,
|
|
}: {
|
|
opened: boolean;
|
|
ownerType: 'MEDICAL_CERTIFICATE' | 'SEA_SERVICE_RECORD';
|
|
ownerId: string | null;
|
|
title: string;
|
|
onClose: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const { data: attachments, isLoading } = useGetAttachmentsQuery(
|
|
{ ownerType, ownerId: ownerId ?? '' },
|
|
{ skip: !ownerId },
|
|
);
|
|
const [preview, setPreview] = useState<{ url: string; title: string } | null>(
|
|
null,
|
|
);
|
|
|
|
const files = useMemo(
|
|
() => (attachments ?? []).flatMap((a) => a.files ?? []),
|
|
[attachments],
|
|
);
|
|
|
|
return (
|
|
<Modal opened={opened} onClose={onClose} title={title} size="md" centered>
|
|
<Stack gap="sm">
|
|
{isLoading ? (
|
|
<Center py="xl">
|
|
<Loader size="sm" />
|
|
</Center>
|
|
) : files.length === 0 ? (
|
|
<Center py="xl">
|
|
<Group gap="xs" c="dimmed">
|
|
<IconInbox size={18} />
|
|
<Text size="sm" c="dimmed">
|
|
{t(
|
|
'recordVerification.noAttachments',
|
|
'No evidence attachments uploaded for this record.',
|
|
)}
|
|
</Text>
|
|
</Group>
|
|
</Center>
|
|
) : (
|
|
files.map((file) => (
|
|
<Paper key={file.id} withBorder p="xs" radius="sm">
|
|
<Group justify="space-between" wrap="nowrap">
|
|
<Group gap="xs" wrap="nowrap" style={{ minWidth: 0 }}>
|
|
<IconPaperclip size={18} />
|
|
<div style={{ minWidth: 0 }}>
|
|
<Text size="sm" fw={500} truncate>
|
|
{file.originalName}
|
|
</Text>
|
|
{file.sizeBytes && (
|
|
<Text size="xs" c="dimmed">
|
|
{(file.sizeBytes / 1024).toFixed(1)} KB
|
|
</Text>
|
|
)}
|
|
</div>
|
|
</Group>
|
|
{file.url ? (
|
|
<Button
|
|
size="compact-xs"
|
|
variant="light"
|
|
leftSection={<IconEye size={14} />}
|
|
onClick={() =>
|
|
setPreview({ url: file.url as string, title: file.originalName })
|
|
}
|
|
>
|
|
{t('recordVerification.view', 'View')}
|
|
</Button>
|
|
) : (
|
|
<Badge size="sm" variant="light" color="gray">
|
|
{t('recordVerification.noUrl', 'No URL')}
|
|
</Badge>
|
|
)}
|
|
</Group>
|
|
</Paper>
|
|
))
|
|
)}
|
|
</Stack>
|
|
<PdfPreviewModal
|
|
opened={Boolean(preview)}
|
|
onClose={() => setPreview(null)}
|
|
url={preview?.url ?? ''}
|
|
title={preview?.title}
|
|
/>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The record-verification workspace (US-SSM-003/007): everything seafarers
|
|
* have submitted and no officer has ruled on yet, oldest first. VERIFIED
|
|
* freezes a record — sea service starts counting toward sea time, a medical
|
|
* certificate starts satisfying the submission gate.
|
|
*/
|
|
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 isMedical = kind === 'medical';
|
|
const [filter, setFilter] = useState<RecordQueueFilter>('SUBMITTED');
|
|
const {
|
|
data: pendingMedical,
|
|
isLoading: loadingMedical,
|
|
isFetching: fetchingMedical,
|
|
refetch: refetchMedical,
|
|
} = useGetPendingMedicalQuery(filter, { skip: !isMedical });
|
|
|
|
const {
|
|
data: pendingSeaService,
|
|
isLoading: loadingSeaService,
|
|
isFetching: fetchingSeaService,
|
|
refetch: refetchSeaService,
|
|
} = useGetPendingSeaServiceQuery(filter, { skip: isMedical });
|
|
|
|
const [verifyMedical, { isLoading: rulingMedical }] =
|
|
useVerifyMedicalCertificateMutation();
|
|
const [verifySeaService, { isLoading: rulingSeaService }] =
|
|
useVerifySeaServiceRecordMutation();
|
|
|
|
const [rejectMedical, setRejectMedical] = useState<MedicalCertificate | null>(
|
|
null,
|
|
);
|
|
const [rejectSeaService, setRejectSeaService] =
|
|
useState<SeaServiceRecord | null>(null);
|
|
|
|
const [attachmentModal, setAttachmentModal] = useState<{
|
|
ownerType: 'MEDICAL_CERTIFICATE' | 'SEA_SERVICE_RECORD';
|
|
ownerId: string;
|
|
title: string;
|
|
} | null>(null);
|
|
|
|
const showDate = useDateDisplayer();
|
|
|
|
const [medicalPage, setMedicalPage] = useState(0);
|
|
const [seaServicePage, setSeaServicePage] = useState(0);
|
|
const [medicalPageSize, setMedicalPageSize] = useState(PAGE_SIZE);
|
|
const [seaServicePageSize, setSeaServicePageSize] = useState(PAGE_SIZE);
|
|
|
|
const rule = useCallback(
|
|
async (run: () => Promise<unknown>, done: string): Promise<void> => {
|
|
try {
|
|
await run();
|
|
notify.success(done);
|
|
} catch (error) {
|
|
notify.error(
|
|
extractErrorMessage(error, t('recordVerification.rulingFailed', 'Could not record the ruling')),
|
|
);
|
|
}
|
|
},
|
|
[t],
|
|
);
|
|
|
|
const pendingMedicalList = useMemo(
|
|
() => pendingMedical ?? [],
|
|
[pendingMedical],
|
|
);
|
|
const pendingSeaServiceList = useMemo(
|
|
() => pendingSeaService ?? [],
|
|
[pendingSeaService],
|
|
);
|
|
|
|
const pagedMedical = useMemo(() => {
|
|
const start = medicalPage * medicalPageSize;
|
|
return pendingMedicalList.slice(start, start + medicalPageSize);
|
|
}, [pendingMedicalList, medicalPage, medicalPageSize]);
|
|
|
|
const pagedSeaService = useMemo(() => {
|
|
const start = seaServicePage * seaServicePageSize;
|
|
return pendingSeaServiceList.slice(start, start + seaServicePageSize);
|
|
}, [pendingSeaServiceList, seaServicePage, seaServicePageSize]);
|
|
|
|
const changeFilter = useCallback((value: string) => {
|
|
setFilter(value as RecordQueueFilter);
|
|
setMedicalPage(0);
|
|
setSeaServicePage(0);
|
|
}, []);
|
|
|
|
const handleMedicalPageSizeChange = useCallback((size: number) => {
|
|
setMedicalPageSize(size);
|
|
setMedicalPage(0);
|
|
}, []);
|
|
|
|
const handleSeaServicePageSizeChange = useCallback((size: number) => {
|
|
setSeaServicePageSize(size);
|
|
setSeaServicePage(0);
|
|
}, []);
|
|
|
|
const statusFilter = (
|
|
<SegmentedControl
|
|
mb="md"
|
|
value={filter}
|
|
onChange={changeFilter}
|
|
data={[
|
|
{
|
|
value: 'SUBMITTED',
|
|
label: t('recordVerification.filter.pending', 'Pending'),
|
|
},
|
|
{
|
|
value: 'VERIFIED',
|
|
label: t('recordVerification.filter.verified', 'Accepted'),
|
|
},
|
|
{
|
|
value: 'REJECTED',
|
|
label: t('recordVerification.filter.rejected', 'Rejected'),
|
|
},
|
|
{ value: 'ALL', label: t('recordVerification.filter.all', 'All') },
|
|
]}
|
|
/>
|
|
);
|
|
|
|
const emptyText =
|
|
filter === 'SUBMITTED'
|
|
? t('recordVerification.emptyText', 'Nothing awaiting verification.')
|
|
: t('recordVerification.emptyTextFiltered', 'No records match this filter.');
|
|
|
|
const medicalTableColumns: AdvancedColumn<MedicalCertificate>[] = useMemo(
|
|
() => [
|
|
...medicalColumns(t, showDate),
|
|
medicalActionsColumn(t, {
|
|
ruling: rulingMedical,
|
|
onEvidence: (certificate) =>
|
|
setAttachmentModal({
|
|
ownerType: 'MEDICAL_CERTIFICATE',
|
|
ownerId: certificate.id,
|
|
title: t('recordVerification.evidenceTitleMedical', {
|
|
name: ownerName(certificate.profile),
|
|
defaultValue: 'Evidence for {{name}} Medical Certificate',
|
|
}),
|
|
}),
|
|
onVerify: (certificate) =>
|
|
rule(
|
|
() =>
|
|
verifyMedical({
|
|
id: certificate.id,
|
|
outcome: 'VERIFIED',
|
|
}).unwrap(),
|
|
t('recordVerification.certificateVerified', 'Certificate verified'),
|
|
),
|
|
onReject: (certificate) => setRejectMedical(certificate),
|
|
}),
|
|
],
|
|
[rulingMedical, rule, verifyMedical, showDate, t],
|
|
);
|
|
|
|
const seaServiceTableColumns: AdvancedColumn<SeaServiceRecord>[] = useMemo(
|
|
() => [
|
|
...seaServiceColumns(t, showDate),
|
|
seaServiceActionsColumn(t, {
|
|
ruling: rulingSeaService,
|
|
onEvidence: (record) =>
|
|
setAttachmentModal({
|
|
ownerType: 'SEA_SERVICE_RECORD',
|
|
ownerId: record.id,
|
|
title: t('recordVerification.evidenceTitleSeaService', {
|
|
name: ownerName(record.profile),
|
|
defaultValue: 'Evidence for {{name}} Sea Service Record',
|
|
}),
|
|
}),
|
|
onVerify: (record) =>
|
|
rule(
|
|
() =>
|
|
verifySeaService({
|
|
id: record.id,
|
|
outcome: 'VERIFIED',
|
|
}).unwrap(),
|
|
t('recordVerification.seaServiceVerified', 'Sea-service record verified'),
|
|
),
|
|
onReject: (record) => setRejectSeaService(record),
|
|
}),
|
|
],
|
|
[rulingSeaService, rule, verifySeaService, showDate, t],
|
|
);
|
|
|
|
return (
|
|
<Container size="xl" py="md">
|
|
<PageHeader
|
|
title={
|
|
isMedical
|
|
? t('recordVerification.medicalTitle', 'Medical Certificate Verification')
|
|
: t('recordVerification.seaServiceTitle', 'Sea Service Verification')
|
|
}
|
|
subtitle={
|
|
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.',
|
|
)
|
|
}
|
|
/>
|
|
|
|
{statusFilter}
|
|
|
|
{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={emptyText}
|
|
/>
|
|
) : (
|
|
<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}
|
|
/>
|
|
)}
|
|
|
|
<AttachmentsModal
|
|
opened={Boolean(attachmentModal)}
|
|
ownerType={attachmentModal?.ownerType ?? 'MEDICAL_CERTIFICATE'}
|
|
ownerId={attachmentModal?.ownerId ?? null}
|
|
title={attachmentModal?.title ?? t('recordVerification.evidenceModalTitle', 'Evidence Attachments')}
|
|
onClose={() => setAttachmentModal(null)}
|
|
/>
|
|
|
|
<RejectModal
|
|
title={t('recordVerification.rejectMedicalTitle', 'Reject medical certificate')}
|
|
opened={Boolean(rejectMedical)}
|
|
onClose={() => setRejectMedical(null)}
|
|
loading={rulingMedical}
|
|
onConfirm={(remark) => {
|
|
if (!rejectMedical) return;
|
|
rule(
|
|
() =>
|
|
verifyMedical({
|
|
id: rejectMedical.id,
|
|
outcome: 'REJECTED',
|
|
remark,
|
|
}).unwrap(),
|
|
t('recordVerification.certificateRejected', 'Certificate rejected'),
|
|
);
|
|
setRejectMedical(null);
|
|
}}
|
|
/>
|
|
<RejectModal
|
|
title={t('recordVerification.rejectSeaServiceTitle', 'Reject sea-service record')}
|
|
opened={Boolean(rejectSeaService)}
|
|
onClose={() => setRejectSeaService(null)}
|
|
loading={rulingSeaService}
|
|
onConfirm={(remark) => {
|
|
if (!rejectSeaService) return;
|
|
rule(
|
|
() =>
|
|
verifySeaService({
|
|
id: rejectSeaService.id,
|
|
outcome: 'REJECTED',
|
|
remark,
|
|
}).unwrap(),
|
|
t('recordVerification.seaServiceRejected', 'Sea-service record rejected'),
|
|
);
|
|
setRejectSeaService(null);
|
|
}}
|
|
/>
|
|
</Container>
|
|
);
|
|
}
|
|
|
|
export const SeaServiceVerificationPage = () => <MedicalVerificationPage kind="sea-service" />;
|
|
|
|
export default MedicalVerificationPage;
|