Files
emaui/apps/backoffice/src/app/features/medical-verification/pages/MedicalVerificationPage/index.tsx

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;