Add AttachmentsModal and refactor MedicalVerificationPage with AdvancedTable integration

This commit is contained in:
Estifo77
2026-08-10 13:06:54 +03:00
parent e93b439d50
commit c822e99052
3 changed files with 397 additions and 196 deletions

View File

@@ -1,15 +1,14 @@
import { useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import {
Badge,
Button,
Card,
Center,
Container,
Group,
Loader,
Modal,
Paper,
Stack,
Table,
Tabs,
Text,
Textarea,
@@ -18,12 +17,16 @@ import {
import {
IconAnchor,
IconCheck,
IconEye,
IconInbox,
IconPaperclip,
IconStethoscope,
IconX,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { AdvancedTable, notify, type AdvancedColumn } from '@ema-platform/ui';
import {
extractErrorMessage,
useGetAttachmentsQuery,
useGetPendingMedicalQuery,
useGetPendingSeaServiceQuery,
useVerifyMedicalCertificateMutation,
@@ -35,6 +38,8 @@ import type {
SeafarerProfileSummary,
} from '@ema-platform/api';
const PAGE_SIZE = 10;
function ownerName(profile?: SeafarerProfileSummary): string {
if (!profile) return '—';
return (
@@ -90,6 +95,89 @@ function RejectModal({
);
}
/** 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 { data: attachments, isLoading } = useGetAttachmentsQuery(
{ ownerType, ownerId: ownerId ?? '' },
{ skip: !ownerId },
);
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">
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} />}
component="a"
href={file.url}
target="_blank"
rel="noopener noreferrer"
>
View
</Button>
) : (
<Badge size="sm" variant="light" color="gray">
No URL
</Badge>
)}
</Group>
</Paper>
))
)}
</Stack>
</Modal>
);
}
/**
* The record-verification workspace (US-SSM-003/007): everything seafarers
* have submitted and no officer has ruled on yet, oldest first. VERIFIED
@@ -97,31 +185,282 @@ function RejectModal({
* certificate starts satisfying the submission gate.
*/
export function MedicalVerificationPage() {
const { data: pendingMedical, isLoading: loadingMedical } =
useGetPendingMedicalQuery();
const { data: pendingSeaService, isLoading: loadingSeaService } =
useGetPendingSeaServiceQuery();
const {
data: pendingMedical,
isLoading: loadingMedical,
isFetching: fetchingMedical,
refetch: refetchMedical,
} = useGetPendingMedicalQuery();
const {
data: pendingSeaService,
isLoading: loadingSeaService,
isFetching: fetchingSeaService,
refetch: refetchSeaService,
} = useGetPendingSeaServiceQuery();
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 rule = async (
run: () => Promise<unknown>,
done: string,
): Promise<void> => {
const [attachmentModal, setAttachmentModal] = useState<{
ownerType: 'MEDICAL_CERTIFICATE' | 'SEA_SERVICE_RECORD';
ownerId: string;
title: string;
} | null>(null);
const [medicalPage, setMedicalPage] = useState(0);
const [seaServicePage, setSeaServicePage] = useState(0);
const rule = useCallback(
async (run: () => Promise<unknown>, done: string): Promise<void> => {
try {
await run();
notify.success(done);
} catch (error) {
notify.error(extractErrorMessage(error, 'Could not record the ruling'));
}
};
},
[],
);
const pendingMedicalList = useMemo(
() => pendingMedical ?? [],
[pendingMedical],
);
const pendingSeaServiceList = useMemo(
() => pendingSeaService ?? [],
[pendingSeaService],
);
const pagedMedical = useMemo(() => {
const start = medicalPage * PAGE_SIZE;
return pendingMedicalList.slice(start, start + PAGE_SIZE);
}, [pendingMedicalList, medicalPage]);
const pagedSeaService = useMemo(() => {
const start = seaServicePage * PAGE_SIZE;
return pendingSeaServiceList.slice(start, start + PAGE_SIZE);
}, [pendingSeaServiceList, seaServicePage]);
const medicalColumns: AdvancedColumn<MedicalCertificate>[] = useMemo(
() => [
{
header: 'Seafarer',
label: 'Seafarer',
accessorKey: 'profile.firstName',
cell: ({ row }) => (
<div>
<Text size="sm" fw={500}>
{ownerName(row.original.profile)}
</Text>
{row.original.profile?.seafarerNumber && (
<Text size="xs" c="dimmed" ff="monospace">
{row.original.profile.seafarerNumber}
</Text>
)}
</div>
),
},
{
header: 'Issuer',
label: 'Issuer',
accessorKey: 'issuerName',
cell: ({ row }) => (
<div>
<Text size="sm">{row.original.issuerName}</Text>
{row.original.certificateNumber && (
<Text size="xs" c="dimmed">
{row.original.certificateNumber}
</Text>
)}
</div>
),
},
{
header: 'Validity',
label: 'Validity',
cell: ({ row }) => (
<Text size="sm">
{row.original.issueDate} {row.original.expiryDate}
</Text>
),
},
{
header: 'Fitness',
label: 'Fitness',
accessorKey: 'fitnessStatus',
cell: ({ row }) => (
<Badge size="sm" variant="light">
{row.original.fitnessStatus}
</Badge>
),
},
{
header: '',
label: 'Actions',
align: 'right',
cell: ({ row }) => (
<Group gap="xs" justify="flex-end" wrap="nowrap">
<Button
size="compact-xs"
color="blue"
variant="light"
leftSection={<IconPaperclip size={14} />}
onClick={() =>
setAttachmentModal({
ownerType: 'MEDICAL_CERTIFICATE',
ownerId: row.original.id,
title: `Evidence for ${ownerName(row.original.profile)} Medical Certificate`,
})
}
>
Evidence
</Button>
<Button
size="compact-xs"
color="teal"
leftSection={<IconCheck size={14} />}
loading={rulingMedical}
onClick={() =>
rule(
() =>
verifyMedical({
id: row.original.id,
outcome: 'VERIFIED',
}).unwrap(),
'Certificate verified',
)
}
>
Verify
</Button>
<Button
size="compact-xs"
color="red"
variant="light"
leftSection={<IconX size={14} />}
onClick={() => setRejectMedical(row.original)}
>
Reject
</Button>
</Group>
),
},
],
[rulingMedical, rule, verifyMedical],
);
const seaServiceColumns: AdvancedColumn<SeaServiceRecord>[] = useMemo(
() => [
{
header: 'Seafarer',
label: 'Seafarer',
accessorKey: 'profile.firstName',
cell: ({ row }) => (
<div>
<Text size="sm" fw={500}>
{ownerName(row.original.profile)}
</Text>
{row.original.profile?.seafarerNumber && (
<Text size="xs" c="dimmed" ff="monospace">
{row.original.profile.seafarerNumber}
</Text>
)}
</div>
),
},
{
header: 'Vessel',
label: 'Vessel',
accessorKey: 'vesselName',
cell: ({ row }) => (
<div>
<Text size="sm">{row.original.vesselName}</Text>
{row.original.imoNumber && (
<Text size="xs" c="dimmed">
IMO {row.original.imoNumber}
</Text>
)}
</div>
),
},
{
header: 'Rank',
label: 'Rank',
accessorKey: 'rank',
cell: ({ row }) => <Text size="sm">{row.original.rank}</Text>,
},
{
header: 'Period',
label: 'Period',
cell: ({ row }) => (
<Text size="sm">
{row.original.engagementDate} {row.original.dischargeDate}
</Text>
),
},
{
header: '',
label: 'Actions',
align: 'right',
cell: ({ row }) => (
<Group gap="xs" justify="flex-end" wrap="nowrap">
<Button
size="compact-xs"
color="blue"
variant="light"
leftSection={<IconPaperclip size={14} />}
onClick={() =>
setAttachmentModal({
ownerType: 'SEA_SERVICE_RECORD',
ownerId: row.original.id,
title: `Evidence for ${ownerName(row.original.profile)} Sea Service Record`,
})
}
>
Evidence
</Button>
<Button
size="compact-xs"
color="teal"
leftSection={<IconCheck size={14} />}
loading={rulingSeaService}
onClick={() =>
rule(
() =>
verifySeaService({
id: row.original.id,
outcome: 'VERIFIED',
}).unwrap(),
'Sea-service record verified',
)
}
>
Verify
</Button>
<Button
size="compact-xs"
color="red"
variant="light"
leftSection={<IconX size={14} />}
onClick={() => setRejectSeaService(row.original)}
>
Reject
</Button>
</Group>
),
},
],
[rulingSeaService, rule, verifySeaService],
);
return (
<Container size="xl" py="md">
@@ -137,192 +476,52 @@ export function MedicalVerificationPage() {
<Tabs defaultValue="medical" keepMounted={false}>
<Tabs.List>
<Tabs.Tab value="medical" leftSection={<IconStethoscope size={16} />}>
Medical ({pendingMedical?.length ?? 0})
Medical ({pendingMedicalList.length})
</Tabs.Tab>
<Tabs.Tab value="sea-service" leftSection={<IconAnchor size={16} />}>
Sea Service ({pendingSeaService?.length ?? 0})
Sea Service ({pendingSeaServiceList.length})
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="medical" pt="md">
<Card withBorder padding={0}>
{loadingMedical ? (
<Center h={160}>
<Loader />
</Center>
) : (pendingMedical ?? []).length === 0 ? (
<Center h={120}>
<Text size="sm" c="dimmed">
Nothing awaiting verification.
</Text>
</Center>
) : (
<Table highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Seafarer</Table.Th>
<Table.Th>Issuer</Table.Th>
<Table.Th>Validity</Table.Th>
<Table.Th>Fitness</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(pendingMedical ?? []).map((certificate) => (
<Table.Tr key={certificate.id}>
<Table.Td>
<Text size="sm" fw={500}>
{ownerName(certificate.profile)}
</Text>
{certificate.profile?.seafarerNumber && (
<Text size="xs" c="dimmed" ff="monospace">
{certificate.profile.seafarerNumber}
</Text>
)}
</Table.Td>
<Table.Td>
{certificate.issuerName}
{certificate.certificateNumber && (
<Text size="xs" c="dimmed">
{certificate.certificateNumber}
</Text>
)}
</Table.Td>
<Table.Td>
{certificate.issueDate} {certificate.expiryDate}
</Table.Td>
<Table.Td>
<Badge size="sm" variant="light">
{certificate.fitnessStatus}
</Badge>
</Table.Td>
<Table.Td>
<Group gap="xs" justify="flex-end" wrap="nowrap">
<Button
size="compact-xs"
color="teal"
leftSection={<IconCheck size={14} />}
loading={rulingMedical}
onClick={() =>
rule(
() =>
verifyMedical({
id: certificate.id,
outcome: 'VERIFIED',
}).unwrap(),
'Certificate verified',
)
}
>
Verify
</Button>
<Button
size="compact-xs"
color="red"
variant="light"
leftSection={<IconX size={14} />}
onClick={() => setRejectMedical(certificate)}
>
Reject
</Button>
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Card>
<AdvancedTable
columns={medicalColumns}
data={pagedMedical}
tableName="Medical Verification"
itemCount={pendingMedicalList.length}
pageIndex={medicalPage}
onPageChange={setMedicalPage}
pageSize={PAGE_SIZE}
refresh={refetchMedical}
isLoading={loadingMedical || fetchingMedical}
emptyText="Nothing awaiting verification."
/>
</Tabs.Panel>
<Tabs.Panel value="sea-service" pt="md">
<Card withBorder padding={0}>
{loadingSeaService ? (
<Center h={160}>
<Loader />
</Center>
) : (pendingSeaService ?? []).length === 0 ? (
<Center h={120}>
<Text size="sm" c="dimmed">
Nothing awaiting verification.
</Text>
</Center>
) : (
<Table highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Seafarer</Table.Th>
<Table.Th>Vessel</Table.Th>
<Table.Th>Rank</Table.Th>
<Table.Th>Period</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(pendingSeaService ?? []).map((record) => (
<Table.Tr key={record.id}>
<Table.Td>
<Text size="sm" fw={500}>
{ownerName(record.profile)}
</Text>
{record.profile?.seafarerNumber && (
<Text size="xs" c="dimmed" ff="monospace">
{record.profile.seafarerNumber}
</Text>
)}
</Table.Td>
<Table.Td>
{record.vesselName}
{record.imoNumber && (
<Text size="xs" c="dimmed">
IMO {record.imoNumber}
</Text>
)}
</Table.Td>
<Table.Td>{record.rank}</Table.Td>
<Table.Td>
{record.engagementDate} {record.dischargeDate}
</Table.Td>
<Table.Td>
<Group gap="xs" justify="flex-end" wrap="nowrap">
<Button
size="compact-xs"
color="teal"
leftSection={<IconCheck size={14} />}
loading={rulingSeaService}
onClick={() =>
rule(
() =>
verifySeaService({
id: record.id,
outcome: 'VERIFIED',
}).unwrap(),
'Sea-service record verified',
)
}
>
Verify
</Button>
<Button
size="compact-xs"
color="red"
variant="light"
leftSection={<IconX size={14} />}
onClick={() => setRejectSeaService(record)}
>
Reject
</Button>
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Card>
<AdvancedTable
columns={seaServiceColumns}
data={pagedSeaService}
tableName="Sea Service Verification"
itemCount={pendingSeaServiceList.length}
pageIndex={seaServicePage}
onPageChange={setSeaServicePage}
pageSize={PAGE_SIZE}
refresh={refetchSeaService}
isLoading={loadingSeaService || fetchingSeaService}
emptyText="Nothing awaiting verification."
/>
</Tabs.Panel>
</Tabs>
<AttachmentsModal
opened={Boolean(attachmentModal)}
ownerType={attachmentModal?.ownerType ?? 'MEDICAL_CERTIFICATE'}
ownerId={attachmentModal?.ownerId ?? null}
title={attachmentModal?.title ?? 'Evidence Attachments'}
onClose={() => setAttachmentModal(null)}
/>
<RejectModal
title="Reject medical certificate"
opened={Boolean(rejectMedical)}

View File

@@ -156,7 +156,7 @@ const EMPTY_SEA_SERVICE = {
};
function SeaServiceTab() {
const { data: records, isLoading } = useGetMySeaServiceRecordsQuery();
const { data: records, isLoading, refetch } = useGetMySeaServiceRecordsQuery();
const { data: seaTime } = useGetMySeaTimeQuery();
const [createRecord, { isLoading: creating }] =
useCreateSeaServiceRecordMutation();
@@ -349,6 +349,7 @@ function SeaServiceTab() {
onPageChange={setPageIndex}
pageSize={pageSize}
isLoading={isLoading}
refresh={refetch}
/>
</Card>
)}
@@ -461,7 +462,7 @@ const EMPTY_MEDICAL = {
};
function MedicalTab() {
const { data: certificates, isLoading } = useGetMyMedicalCertificatesQuery();
const { data: certificates, isLoading, refetch } = useGetMyMedicalCertificatesQuery();
const [createCertificate, { isLoading: creating }] =
useCreateMedicalCertificateMutation();
const [updateCertificate, { isLoading: updating }] =
@@ -654,6 +655,7 @@ function MedicalTab() {
onPageChange={setPageIndex}
pageSize={pageSize}
isLoading={isLoading}
refresh={refetch}
/>
</Card>
)}

View File

@@ -85,7 +85,7 @@ export function AdvancedTable<T extends { id?: string | number }>({
const shownColumns = columns.filter((_, i) => visible[i] ?? true);
return (
<Paper radius="md" p="md">
<Paper withBorder radius="md" p="md">
<Group justify="space-between" mb="md">
<Group gap="xs">
<Text fw={600}>{""}</Text>