diff --git a/apps/portal/src/app/features/seafarer/pages/MySeaRecordsPage/actions.tsx b/apps/portal/src/app/features/seafarer/pages/MySeaRecordsPage/actions.tsx new file mode 100644 index 000000000..28a3e4dc2 --- /dev/null +++ b/apps/portal/src/app/features/seafarer/pages/MySeaRecordsPage/actions.tsx @@ -0,0 +1,111 @@ +import { ActionIcon, Group, Tooltip } from '@mantine/core'; +import { IconEdit, IconPaperclip, IconTrash } from '@tabler/icons-react'; +import type { AdvancedColumn } from '@ema-platform/ui'; +import type { MedicalCertificate, SeaServiceRecord } from '@ema-platform/api'; +import { PORTAL_PERMISSIONS } from '@ema-platform/auth'; + +export function seaServiceActionsColumn(handlers: { + /** Permission check from usePermissions() — hooks can't run in a cell. */ + can: (required?: string[]) => boolean; + onEvidence: (record: SeaServiceRecord) => void; + onEdit: (record: SeaServiceRecord) => void; + onDelete: (record: SeaServiceRecord) => void; +}): AdvancedColumn { + return { + header: '', + label: 'Actions', + align: 'right', + cell: ({ row }) => { + const record = row.original; + const locked = record.status !== 'SUBMITTED'; + return ( + + + handlers.onEvidence(record)} + > + + + + {handlers.can([PORTAL_PERMISSIONS.EDIT_SEA_SERVICE]) && ( + <> + + handlers.onEdit(record)} + > + + + + + handlers.onDelete(record)} + > + + + + + )} + + ); + }, + }; +} + +export function medicalActionsColumn(handlers: { + /** Permission check from usePermissions() — hooks can't run in a cell. */ + can: (required?: string[]) => boolean; + onEvidence: (certificate: MedicalCertificate) => void; + onEdit: (certificate: MedicalCertificate) => void; + onDelete: (certificate: MedicalCertificate) => void; +}): AdvancedColumn { + return { + header: '', + label: 'Actions', + align: 'right', + cell: ({ row }) => { + const certificate = row.original; + const locked = certificate.status !== 'SUBMITTED'; + return ( + + + handlers.onEvidence(certificate)} + > + + + + {handlers.can([PORTAL_PERMISSIONS.UPLOAD_MEDICAL]) && ( + <> + + handlers.onEdit(certificate)} + > + + + + + handlers.onDelete(certificate)} + > + + + + + )} + + ); + }, + }; +} diff --git a/apps/portal/src/app/features/seafarer/pages/MySeaRecordsPage/columns.tsx b/apps/portal/src/app/features/seafarer/pages/MySeaRecordsPage/columns.tsx new file mode 100644 index 000000000..c51abaeb2 --- /dev/null +++ b/apps/portal/src/app/features/seafarer/pages/MySeaRecordsPage/columns.tsx @@ -0,0 +1,117 @@ +import { Badge, Group, Text, Tooltip } from '@mantine/core'; +import type { AdvancedColumn } from '@ema-platform/ui'; +import type { MedicalCertificate, SeaServiceRecord } from '@ema-platform/api'; + +const RECORD_STATUS_COLORS: Record = { + SUBMITTED: 'blue', + VERIFIED: 'green', + REJECTED: 'red', +}; + +export const FITNESS_OPTIONS = [ + { value: 'FIT', label: 'Fit' }, + { value: 'FIT_WITH_RESTRICTIONS', label: 'Fit with restrictions' }, + { value: 'UNFIT', label: 'Unfit' }, +]; + +export function seaServiceColumns( + showDate: (date: string) => string, +): AdvancedColumn[] { + return [ + { + header: 'Vessel', + cell: ({ row }) => ( + <> + + {row.original.vesselName} + + {row.original.imoNumber && ( + + IMO {row.original.imoNumber} + + )} + + ), + }, + { header: 'Rank', accessorKey: 'rank' }, + { + header: 'From', + accessorKey: 'engagementDate', + cell: ({ row }) => showDate(row.original.engagementDate), + }, + { + header: 'To', + accessorKey: 'dischargeDate', + cell: ({ row }) => showDate(row.original.dischargeDate), + }, + { + header: 'Status', + cell: ({ row }) => ( + + + {row.original.status} + + + ), + }, + ]; +} + +export function medicalColumns( + showDate: (date: string) => string, +): AdvancedColumn[] { + const today = new Date().toISOString().slice(0, 10); + return [ + { + header: 'Issuer', + cell: ({ row }) => ( + <> + + {row.original.issuerName} + + {row.original.certificateNumber && ( + + № {row.original.certificateNumber} + + )} + + ), + }, + { + header: 'Issued', + accessorKey: 'issueDate', + cell: ({ row }) => showDate(row.original.issueDate), + }, + { + header: 'Expires', + cell: ({ row }) => ( + + {showDate(row.original.expiryDate)} + {row.original.expiryDate < today && Expired} + + ), + }, + { + header: 'Fitness', + cell: ({ row }) => + FITNESS_OPTIONS.find((o) => o.value === row.original.fitnessStatus) + ?.label ?? row.original.fitnessStatus, + }, + { + header: 'Status', + cell: ({ row }) => ( + + + {row.original.status} + + + ), + }, + ]; +} diff --git a/apps/portal/src/app/features/seafarer/pages/MySeaRecordsPage/index.tsx b/apps/portal/src/app/features/seafarer/pages/MySeaRecordsPage/index.tsx new file mode 100644 index 000000000..c8a7f7b90 --- /dev/null +++ b/apps/portal/src/app/features/seafarer/pages/MySeaRecordsPage/index.tsx @@ -0,0 +1,637 @@ +import { + Alert, + Anchor, + Badge, + Button, + Card, + FileButton, + Group, + Loader, + Modal, + NumberInput, + Paper, + Select, + Stack, + Tabs, + Text, + TextInput, + Textarea, + Title, +} from '@mantine/core'; +import { + IconAnchor, + IconFileUpload, + IconInfoCircle, + IconPaperclip, + IconPlus, + IconStethoscope, +} from '@tabler/icons-react'; +import { useState } from 'react'; +import { AdvancedTable, AmharicDatePicker, notify, useServerTable } from '@ema-platform/ui'; +import { useDateDisplayer } from '@ema-platform/shared'; +import { + extractErrorMessage, + uploadDocument, + useCreateMedicalCertificateMutation, + useCreateSeaServiceRecordMutation, + useDeleteMedicalCertificateMutation, + useDeleteSeaServiceRecordMutation, + useGetAttachmentsQuery, + useGetMyMedicalCertificatesQuery, + useGetMySeaServiceRecordsQuery, + useGetMySeaTimeQuery, + useUpdateMedicalCertificateMutation, + useUpdateSeaServiceRecordMutation, +} from '@ema-platform/api'; +import type { MedicalCertificate, SeaServiceRecord } from '@ema-platform/api'; +import { + PORTAL_PERMISSIONS, + RequirePermission, + usePermissions, +} from '@ema-platform/auth'; +import { seaServiceColumns, medicalColumns, FITNESS_OPTIONS } from './columns'; +import { seaServiceActionsColumn, medicalActionsColumn } from './actions'; + +/** + * Evidence viewer/uploader shared by both record kinds. + * + * Files upload against the record itself (`SEA_SERVICE_RECORD` / + * `MEDICAL_CERTIFICATE` owner types), so the officer verifying it later opens + * exactly what the seafarer attached. + */ +function EvidenceModal({ + ownerType, + ownerId, + onClose, +}: { + ownerType: 'SEA_SERVICE_RECORD' | 'MEDICAL_CERTIFICATE'; + ownerId: string | null; + onClose: () => void; +}) { + const { data: attachments, refetch, isLoading } = useGetAttachmentsQuery( + { ownerType, ownerId: ownerId ?? '' }, + { skip: !ownerId }, + ); + const [uploading, setUploading] = useState(false); + + const upload = async (file: File | null) => { + if (!file || !ownerId) return; + setUploading(true); + const result = await uploadDocument({ + ownerType, + ownerId, + documentKey: 'evidence', + file, + }); + setUploading(false); + if (result.ok) { + notify.success('Evidence uploaded'); + refetch(); + } else { + notify.error(result.error); + } + }; + + const files = (attachments ?? []).flatMap((a) => a.files); + + return ( + + + {isLoading ? ( + + ) : files.length === 0 ? ( + + No evidence uploaded yet. + + ) : ( + files.map((file) => ( + + + {file.url ? ( + + {file.originalName} + + ) : ( + {file.originalName} + )} + + )) + )} + + + {(props) => ( + + )} + + + + + ); +} + +// ---------------------------------------------------------------- sea service + +const EMPTY_SEA_SERVICE = { + vesselName: '', + imoNumber: '', + vesselType: '', + flagState: '', + rank: '', + engagementDate: '', + dischargeDate: '', + dutiesDescription: '', +}; + +function SeaServiceTab() { + const showDate = useDateDisplayer(); + const { can } = usePermissions(); + const { data: records, isLoading, refetch } = useGetMySeaServiceRecordsQuery(); + const { data: seaTime } = useGetMySeaTimeQuery(); + const [createRecord, { isLoading: creating }] = + useCreateSeaServiceRecordMutation(); + const [updateRecord, { isLoading: updating }] = + useUpdateSeaServiceRecordMutation(); + const [deleteRecord] = useDeleteSeaServiceRecordMutation(); + + const [editing, setEditing] = useState(null); + const [modalOpen, setModalOpen] = useState(false); + const [evidenceFor, setEvidenceFor] = useState(null); + const [form, setForm] = useState(EMPTY_SEA_SERVICE); + const [grossTonnage, setGrossTonnage] = useState(''); + + const openCreate = () => { + setEditing(null); + setForm(EMPTY_SEA_SERVICE); + setGrossTonnage(''); + setModalOpen(true); + }; + + const openEdit = (record: SeaServiceRecord) => { + setEditing(record); + setForm({ + vesselName: record.vesselName, + imoNumber: record.imoNumber ?? '', + vesselType: record.vesselType ?? '', + flagState: record.flagState ?? '', + rank: record.rank, + engagementDate: record.engagementDate, + dischargeDate: record.dischargeDate, + dutiesDescription: record.dutiesDescription ?? '', + }); + setGrossTonnage(record.grossTonnage ? Number(record.grossTonnage) : ''); + setModalOpen(true); + }; + + const save = async () => { + const body = { + vesselName: form.vesselName, + rank: form.rank, + engagementDate: form.engagementDate, + dischargeDate: form.dischargeDate, + ...(form.imoNumber ? { imoNumber: form.imoNumber } : {}), + ...(form.vesselType ? { vesselType: form.vesselType } : {}), + ...(form.flagState ? { flagState: form.flagState } : {}), + ...(form.dutiesDescription + ? { dutiesDescription: form.dutiesDescription } + : {}), + ...(grossTonnage !== '' ? { grossTonnage: Number(grossTonnage) } : {}), + }; + try { + if (editing) { + await updateRecord({ id: editing.id, body }).unwrap(); + notify.success('Sea-service record updated'); + } else { + await createRecord(body).unwrap(); + notify.success('Sea-service record added'); + } + setModalOpen(false); + } catch (error) { + notify.error(extractErrorMessage(error, 'Could not save the record')); + } + }; + + const remove = async (record: SeaServiceRecord) => { + try { + await deleteRecord(record.id).unwrap(); + notify.success('Record withdrawn'); + } catch (error) { + notify.error(extractErrorMessage(error, 'Could not delete the record')); + } + }; + + const valid = + form.vesselName.trim().length > 1 && + form.rank.trim().length > 1 && + form.engagementDate && + form.dischargeDate && + form.engagementDate < form.dischargeDate; + + const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable({ pageSize: 10 }); + const page = paginate(records ?? []); + + const columns = [ + ...seaServiceColumns(showDate), + seaServiceActionsColumn({ + can, + onEvidence: (record) => setEvidenceFor(record.id), + onEdit: openEdit, + onDelete: remove, + }), + ]; + + return ( + + + + + Every engagement aboard a vessel, with its evidence. Verified + records feed certificate eligibility. + + {seaTime && seaTime.verifiedRecords > 0 && ( + + Approved sea time: {seaTime.totalDays} days + + )} + + + + + + {(records ?? []).length === 0 ? ( + + + No sea-service records yet. + + + ) : ( + + + + )} + + setModalOpen(false)} + title={editing ? 'Edit sea service' : 'Add sea service'} + centered + size="lg" + > + + + setForm({ ...form, vesselName: e.target.value })} + /> + setForm({ ...form, imoNumber: e.target.value })} + /> + + + setForm({ ...form, vesselType: e.target.value })} + /> + setForm({ ...form, flagState: e.target.value })} + /> + setGrossTonnage(typeof v === 'number' ? v : '')} + /> + + setForm({ ...form, rank: e.target.value })} + /> + + + setForm({ ...form, engagementDate: val }) + } + dateFormat="date" + /> + + setForm({ ...form, dischargeDate: val }) + } + dateFormat="date" + /> + +