From c822e99052fe1568c99b585bce7f24b9bcf50e7a Mon Sep 17 00:00:00 2001 From: Estifo77 <139631617+Estifo77@users.noreply.github.com> Date: Mon, 10 Aug 2026 13:06:54 +0300 Subject: [PATCH] Add AttachmentsModal and refactor MedicalVerificationPage with AdvancedTable integration --- .../pages/MedicalVerificationPage.tsx | 585 ++++++++++++------ .../seafarer/pages/MySeaRecordsPage.tsx | 6 +- libs/ui/src/lib/data/AdvancedTable.tsx | 2 +- 3 files changed, 397 insertions(+), 196 deletions(-) diff --git a/apps/backoffice/src/app/features/medical-verification/pages/MedicalVerificationPage.tsx b/apps/backoffice/src/app/features/medical-verification/pages/MedicalVerificationPage.tsx index ee874f416..44bb9123e 100644 --- a/apps/backoffice/src/app/features/medical-verification/pages/MedicalVerificationPage.tsx +++ b/apps/backoffice/src/app/features/medical-verification/pages/MedicalVerificationPage.tsx @@ -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 ( + + + {isLoading ? ( +
+ +
+ ) : files.length === 0 ? ( +
+ + + + No evidence attachments uploaded for this record. + + +
+ ) : ( + files.map((file) => ( + + + + +
+ + {file.originalName} + + {file.sizeBytes && ( + + {(file.sizeBytes / 1024).toFixed(1)} KB + + )} +
+
+ {file.url ? ( + + ) : ( + + No URL + + )} +
+
+ )) + )} +
+
+ ); +} + /** * 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( null, ); const [rejectSeaService, setRejectSeaService] = useState(null); - const rule = async ( - run: () => Promise, - done: string, - ): Promise => { - try { - await run(); - notify.success(done); - } catch (error) { - notify.error(extractErrorMessage(error, 'Could not record the ruling')); - } - }; + 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, done: string): Promise => { + 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[] = useMemo( + () => [ + { + header: 'Seafarer', + label: 'Seafarer', + accessorKey: 'profile.firstName', + cell: ({ row }) => ( +
+ + {ownerName(row.original.profile)} + + {row.original.profile?.seafarerNumber && ( + + {row.original.profile.seafarerNumber} + + )} +
+ ), + }, + { + header: 'Issuer', + label: 'Issuer', + accessorKey: 'issuerName', + cell: ({ row }) => ( +
+ {row.original.issuerName} + {row.original.certificateNumber && ( + + № {row.original.certificateNumber} + + )} +
+ ), + }, + { + header: 'Validity', + label: 'Validity', + cell: ({ row }) => ( + + {row.original.issueDate} → {row.original.expiryDate} + + ), + }, + { + header: 'Fitness', + label: 'Fitness', + accessorKey: 'fitnessStatus', + cell: ({ row }) => ( + + {row.original.fitnessStatus} + + ), + }, + { + header: '', + label: 'Actions', + align: 'right', + cell: ({ row }) => ( + + + + + + ), + }, + ], + [rulingMedical, rule, verifyMedical], + ); + + const seaServiceColumns: AdvancedColumn[] = useMemo( + () => [ + { + header: 'Seafarer', + label: 'Seafarer', + accessorKey: 'profile.firstName', + cell: ({ row }) => ( +
+ + {ownerName(row.original.profile)} + + {row.original.profile?.seafarerNumber && ( + + {row.original.profile.seafarerNumber} + + )} +
+ ), + }, + { + header: 'Vessel', + label: 'Vessel', + accessorKey: 'vesselName', + cell: ({ row }) => ( +
+ {row.original.vesselName} + {row.original.imoNumber && ( + + IMO {row.original.imoNumber} + + )} +
+ ), + }, + { + header: 'Rank', + label: 'Rank', + accessorKey: 'rank', + cell: ({ row }) => {row.original.rank}, + }, + { + header: 'Period', + label: 'Period', + cell: ({ row }) => ( + + {row.original.engagementDate} → {row.original.dischargeDate} + + ), + }, + { + header: '', + label: 'Actions', + align: 'right', + cell: ({ row }) => ( + + + + + + ), + }, + ], + [rulingSeaService, rule, verifySeaService], + ); return ( @@ -137,192 +476,52 @@ export function MedicalVerificationPage() { }> - Medical ({pendingMedical?.length ?? 0}) + Medical ({pendingMedicalList.length}) }> - Sea Service ({pendingSeaService?.length ?? 0}) + Sea Service ({pendingSeaServiceList.length}) - - {loadingMedical ? ( -
- -
- ) : (pendingMedical ?? []).length === 0 ? ( -
- - Nothing awaiting verification. - -
- ) : ( - - - - Seafarer - Issuer - Validity - Fitness - - - - - {(pendingMedical ?? []).map((certificate) => ( - - - - {ownerName(certificate.profile)} - - {certificate.profile?.seafarerNumber && ( - - {certificate.profile.seafarerNumber} - - )} - - - {certificate.issuerName} - {certificate.certificateNumber && ( - - № {certificate.certificateNumber} - - )} - - - {certificate.issueDate} → {certificate.expiryDate} - - - - {certificate.fitnessStatus} - - - - - - - - - - ))} - -
- )} -
+
- - {loadingSeaService ? ( -
- -
- ) : (pendingSeaService ?? []).length === 0 ? ( -
- - Nothing awaiting verification. - -
- ) : ( - - - - Seafarer - Vessel - Rank - Period - - - - - {(pendingSeaService ?? []).map((record) => ( - - - - {ownerName(record.profile)} - - {record.profile?.seafarerNumber && ( - - {record.profile.seafarerNumber} - - )} - - - {record.vesselName} - {record.imoNumber && ( - - IMO {record.imoNumber} - - )} - - {record.rank} - - {record.engagementDate} → {record.dischargeDate} - - - - - - - - - ))} - -
- )} -
+
+ setAttachmentModal(null)} + /> + )} @@ -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} /> )} diff --git a/libs/ui/src/lib/data/AdvancedTable.tsx b/libs/ui/src/lib/data/AdvancedTable.tsx index 4fcae4570..6ec59a3c7 100644 --- a/libs/ui/src/lib/data/AdvancedTable.tsx +++ b/libs/ui/src/lib/data/AdvancedTable.tsx @@ -85,7 +85,7 @@ export function AdvancedTable({ const shownColumns = columns.filter((_, i) => visible[i] ?? true); return ( - + {""}