feat: implement DocumentVaultPage to display seafarer licenses, medical certificates, and official documents with PDF preview functionality.

This commit is contained in:
estifanos
2026-08-28 07:56:04 +00:00
parent c1005cf927
commit c26528dad5
3 changed files with 627 additions and 9 deletions

View File

@@ -1,21 +1,547 @@
import { Container } from '@mantine/core';
import { FeatureUnavailable } from '@ema-platform/ui';
import { useState } from 'react';
import { Link } from 'react-router-dom';
import {
Alert,
Anchor,
Badge,
Button,
Card,
Container,
Divider,
Group,
Loader,
SimpleGrid,
Stack,
Tabs,
Text,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconAnchor,
IconCertificate,
IconEye,
IconHeartbeat,
IconIdBadge2,
IconInfoCircle,
IconPaperclip,
} from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { useDateDisplayer } from '@ema-platform/shared';
import { notify, PdfPreviewModal } from '@ema-platform/ui';
import {
extractErrorMessage,
useGetAttachmentsQuery,
useGetCertificateUrlMutation,
useGetMyLicensesQuery,
useGetMyMedicalCertificatesQuery,
useGetMySeaServiceRecordsQuery,
useGetMySeafarerDocumentsQuery,
useGetMySeafarerRegistrationQuery,
useLazyGetMySeafarerDocumentDownloadQuery,
type SeafarerDocument,
type SeafarerRecordStatus,
} from '@ema-platform/api';
import { LicenseCard, useRenewLicense } from '../../licensing/components/LicenseCard';
import { documentSlots } from '../../seafarer-registration/components/RegistrationDocuments';
type Preview = { url: string; title: string };
const RECORD_STATUS_COLOR: Record<SeafarerRecordStatus, string> = {
SUBMITTED: 'blue',
VERIFIED: 'teal',
REJECTED: 'red',
};
/**
* Placeholder until this feature has a backend.
*
* This page previously rendered invented figures/records that were
* indistinguishable from real ones.
* The files attached to one record, read from the attachment store the record
* owns. One query per card rather than a page-wide fetch: `/attachments` is
* owner-scoped, and RTK Query dedupes and caches them by owner anyway.
*/
function RecordFiles({
ownerType,
ownerId,
onPreview,
}: {
ownerType: 'SEA_SERVICE_RECORD' | 'MEDICAL_CERTIFICATE' | 'SEAFARER_REGISTRATION';
ownerId: string;
onPreview: (preview: Preview) => void;
}) {
const { t } = useTranslation();
const { data, isLoading } = useGetAttachmentsQuery({ ownerType, ownerId });
const files = (data ?? []).flatMap((a) => a.files);
if (isLoading) return <Loader size="xs" type="oval" />;
if (files.length === 0)
return (
<Text fz="xs" c="dimmed">
{t('documents.files.none')}
</Text>
);
return (
<Stack gap={4}>
{files.map((file) => (
<Group key={file.id} gap={6} wrap="nowrap">
<IconPaperclip size={14} />
{file.url ? (
<Anchor
component="button"
type="button"
fz="xs"
onClick={() => onPreview({ url: file.url as string, title: file.originalName })}
>
{file.originalName}
</Anchor>
) : (
<Text fz="xs">{file.originalName}</Text>
)}
</Group>
))}
</Stack>
);
}
function FieldRow({ label, value }: { label: string; value: string }) {
return (
<Group justify="space-between" gap="xs" wrap="nowrap">
<Text fz="xs" c="dimmed">
{label}
</Text>
<Text fz="xs" fw={500} ta="right">
{value}
</Text>
</Group>
);
}
/** Seaman Book / BTC — issued on their own workflow, not as licences. */
function IssuedDocumentCard({
document,
onPreview,
}: {
document: SeafarerDocument;
onPreview: (preview: Preview) => void;
}) {
const { t } = useTranslation();
const showDate = useDateDisplayer();
const [download, { isFetching }] = useLazyGetMySeafarerDocumentDownloadQuery();
const issued = document.status === 'ISSUED';
async function open() {
try {
const { url } = await download(document.id).unwrap();
onPreview({ url, title: t(`documents.kind.${document.kind}`) });
} catch (err) {
notify.error(extractErrorMessage(err), t('documents.openFailed'));
}
}
return (
<Card withBorder radius="md" padding="md">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Group gap="sm" wrap="nowrap">
<ThemeIcon size="lg" variant="light" color={issued ? 'teal' : 'gray'} radius="md">
<IconCertificate size={18} stroke={1.5} />
</ThemeIcon>
<div>
<Text fz="sm" fw={600}>
{t(`documents.kind.${document.kind}`)}
</Text>
<Text fz="xs" c="dimmed">
{document.documentNumber ?? document.requestNumber}
</Text>
</div>
</Group>
<Badge size="sm" variant="light" color={issued ? 'teal' : 'gray'}>
{t(`documents.documentStatus.${document.status}`, { defaultValue: document.status })}
</Badge>
</Group>
<Divider my="sm" />
<Stack gap={4}>
{document.issueDate && (
<FieldRow label={t('seaRecords.columns.issued')} value={showDate(document.issueDate)} />
)}
{document.expiryDate && (
<FieldRow label={t('seaRecords.columns.expires')} value={showDate(document.expiryDate)} />
)}
</Stack>
<Button
mt="sm"
size="xs"
variant="light"
fullWidth
leftSection={<IconEye size={14} />}
disabled={!issued}
loading={isFetching}
onClick={open}
>
{issued ? t('documents.view') : t('documents.notIssued')}
</Button>
</Card>
);
}
export function DocumentVaultPage() {
const { t } = useTranslation();
const showDate = useDateDisplayer();
const [preview, setPreview] = useState<Preview | null>(null);
const { data: licences, isLoading: loadingLicences } = useGetMyLicensesQuery();
const { data: issuedDocuments } = useGetMySeafarerDocumentsQuery();
const { data: medicals, isLoading: loadingMedicals } = useGetMyMedicalCertificatesQuery();
const { data: seaService, isLoading: loadingSeaService } = useGetMySeaServiceRecordsQuery();
const { data: registrationData, isLoading: loadingRegistration } =
useGetMySeafarerRegistrationQuery();
const [getCertificateUrl, { isLoading: isDownloadingCert }] = useGetCertificateUrlMutation();
const { renewLicense, isRenewing } = useRenewLicense();
const registration = registrationData?.registration ?? null;
const { data: registrationFiles } = useGetAttachmentsQuery(
{ ownerType: 'SEAFARER_REGISTRATION', ownerId: registration?.id ?? '' },
{ skip: !registration },
);
async function openCertificate(licenseId: string) {
try {
const { url } = await getCertificateUrl(licenseId).unwrap();
setPreview({ url, title: t('licensing.card.downloadCertificate') });
} catch (err) {
notify.error(extractErrorMessage(err), t('documents.openFailed'));
}
}
const licenceItems = licences?.items ?? [];
const issued = [issuedDocuments?.seamanBook, issuedDocuments?.btc].filter(
(d): d is SeafarerDocument => Boolean(d),
);
return (
<Container size="lg" py="xl">
<FeatureUnavailable
title={t('featureUnavailable.documents.title')}
description={t('featureUnavailable.documents.description')}
<Stack gap="md">
<div>
<Title order={3}>{t('documents.title')}</Title>
<Text fz="sm" c="dimmed">
{t('documents.subtitle')}
</Text>
</div>
<Tabs defaultValue="license" variant="outline" radius="md" keepMounted={false}>
<Tabs.List mb="md">
<Tabs.Tab value="license" leftSection={<IconCertificate size={16} />}>
{t('documents.tabs.license')}
</Tabs.Tab>
<Tabs.Tab value="medical" leftSection={<IconHeartbeat size={16} />}>
{t('documents.tabs.medical')}
</Tabs.Tab>
<Tabs.Tab value="sea-service" leftSection={<IconAnchor size={16} />}>
{t('documents.tabs.seaService')}
</Tabs.Tab>
<Tabs.Tab value="personal" leftSection={<IconIdBadge2 size={16} />}>
{t('documents.tabs.personal')}
</Tabs.Tab>
</Tabs.List>
{/* ── Licences and EMA-issued documents ───────────────────────── */}
<Tabs.Panel value="license">
<Stack gap="xl">
{issued.length > 0 && (
<div>
<Text fw={700} fz="sm" mb="sm" tt="uppercase" c="dimmed">
{t('documents.issuedTitle')}
</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{issued.map((document) => (
<IssuedDocumentCard
key={document.id}
document={document}
onPreview={setPreview}
/>
))}
</SimpleGrid>
</div>
)}
<div>
<Text fw={700} fz="sm" mb="sm" tt="uppercase" c="dimmed">
{t('documents.licensesTitle')}
</Text>
{loadingLicences ? (
<Loader size="sm" type="oval" />
) : licenceItems.length === 0 ? (
<Text fz="sm" c="dimmed">
{t('documents.empty.licenses')}
</Text>
) : (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{licenceItems.map((licence) => (
<LicenseCard
key={licence.id}
license={licence}
isDownloading={isDownloadingCert}
isRenewing={isRenewing}
onDownload={() => openCertificate(licence.id)}
onRenew={() => renewLicense(licence)}
/>
))}
</SimpleGrid>
)}
</div>
</Stack>
</Tabs.Panel>
{/* ── Medical certificates ────────────────────────────────────── */}
<Tabs.Panel value="medical">
{loadingMedicals ? (
<Loader size="sm" type="oval" />
) : (medicals ?? []).length === 0 ? (
<Text fz="sm" c="dimmed">
{t('documents.empty.medical')}
</Text>
) : (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{(medicals ?? []).map((record) => (
<Card key={record.id} withBorder radius="md" padding="md">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Group gap="sm" wrap="nowrap">
<ThemeIcon size="lg" variant="light" color="pink" radius="md">
<IconHeartbeat size={18} stroke={1.5} />
</ThemeIcon>
<div>
<Text fz="sm" fw={600}>
{record.issuerName}
</Text>
{record.certificateNumber && (
<Text fz="xs" c="dimmed">
{t('seaRecords.columns.certNumber', {
number: record.certificateNumber,
})}
</Text>
)}
</div>
</Group>
<Badge size="sm" variant="light" color={RECORD_STATUS_COLOR[record.status]}>
{t(`seaRecords.columns.recordStatus.${record.status}`, {
defaultValue: record.status,
})}
</Badge>
</Group>
<Divider my="sm" />
<Stack gap={4}>
<FieldRow
label={t('seaRecords.columns.issued')}
value={showDate(record.issueDate)}
/>
<FieldRow
label={t('seaRecords.columns.expires')}
value={showDate(record.expiryDate)}
/>
<FieldRow
label={t('seaRecords.columns.fitness')}
value={t(`seaRecords.columns.fitnessOptions.${record.fitnessStatus}`, {
defaultValue: record.fitnessStatus,
})}
/>
</Stack>
<Divider my="sm" />
<RecordFiles
ownerType="MEDICAL_CERTIFICATE"
ownerId={record.id}
onPreview={setPreview}
/>
</Card>
))}
</SimpleGrid>
)}
</Tabs.Panel>
{/* ── Sea service records ─────────────────────────────────────── */}
<Tabs.Panel value="sea-service">
{loadingSeaService ? (
<Loader size="sm" type="oval" />
) : (seaService ?? []).length === 0 ? (
<Text fz="sm" c="dimmed">
{t('documents.empty.seaService')}
</Text>
) : (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{(seaService ?? []).map((record) => (
<Card key={record.id} withBorder radius="md" padding="md">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Group gap="sm" wrap="nowrap">
<ThemeIcon size="lg" variant="light" color="blue" radius="md">
<IconAnchor size={18} stroke={1.5} />
</ThemeIcon>
<div>
<Text fz="sm" fw={600}>
{record.vesselName}
</Text>
{record.imoNumber && (
<Text fz="xs" c="dimmed">
{t('seaRecords.columns.imo', { number: record.imoNumber })}
</Text>
)}
</div>
</Group>
<Badge size="sm" variant="light" color={RECORD_STATUS_COLOR[record.status]}>
{t(`seaRecords.columns.recordStatus.${record.status}`, {
defaultValue: record.status,
})}
</Badge>
</Group>
<Divider my="sm" />
<Stack gap={4}>
<FieldRow label={t('seaRecords.columns.rank')} value={record.rank} />
<FieldRow
label={t('seaRecords.columns.from')}
value={showDate(record.engagementDate)}
/>
<FieldRow
label={t('seaRecords.columns.to')}
value={showDate(record.dischargeDate)}
/>
</Stack>
<Divider my="sm" />
<RecordFiles
ownerType="SEA_SERVICE_RECORD"
ownerId={record.id}
onPreview={setPreview}
/>
</Card>
))}
</SimpleGrid>
)}
</Tabs.Panel>
{/* ── Personal documents from the seafarer registration ───────── */}
<Tabs.Panel value="personal">
{loadingRegistration ? (
<Loader size="sm" type="oval" />
) : !registration ? (
// Personal documents are uploaded against a registration, so
// without one there is nothing to show — and nowhere to put an
// upload either.
<Alert color="blue" icon={<IconInfoCircle size={16} />}>
<Stack gap="xs" align="flex-start">
<Text fz="sm">{t('documents.personal.noRegistration')}</Text>
<Button
size="xs"
variant="light"
component={Link}
to="/seafarer-registration"
>
{t('documents.personal.startRegistration')}
</Button>
</Stack>
</Alert>
) : (
<Stack gap="md">
<Text fz="sm" c="dimmed">
{t('documents.personal.description')}
</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{documentSlots(
Boolean(registration.passportNumber),
registration.nationality,
).map((slot) => {
const attachment = (registrationFiles ?? []).find(
(a) => a.documentKey === slot.key,
);
const files = attachment?.files ?? [];
return (
<Card
key={slot.key}
withBorder
radius="md"
padding="md"
style={{
borderStyle: files.length ? 'solid' : 'dashed',
borderColor: files.length
? 'var(--mantine-color-teal-4)'
: slot.isRequired
? 'var(--mantine-color-orange-4)'
: undefined,
}}
>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div>
<Text fz="sm" fw={600}>
{slot.name}
</Text>
{slot.description && (
<Text fz="xs" c="dimmed">
{slot.description}
</Text>
)}
</div>
<Badge
size="sm"
variant="light"
color={files.length ? 'teal' : slot.isRequired ? 'orange' : 'gray'}
>
{files.length
? t('documents.personal.uploaded')
: t('documents.personal.missing')}
</Badge>
</Group>
<Divider my="sm" />
{files.length === 0 ? (
<Text fz="xs" c="dimmed">
{t('documents.files.none')}
</Text>
) : (
<Stack gap={4}>
{files.map((file) => (
<Group key={file.id} gap={6} wrap="nowrap">
<IconPaperclip size={14} />
{file.url ? (
<Anchor
component="button"
type="button"
fz="xs"
onClick={() =>
setPreview({
url: file.url as string,
title: file.originalName,
})
}
>
{file.originalName}
</Anchor>
) : (
<Text fz="xs">{file.originalName}</Text>
)}
</Group>
))}
</Stack>
)}
</Card>
);
})}
</SimpleGrid>
</Stack>
)}
</Tabs.Panel>
</Tabs>
</Stack>
<PdfPreviewModal
opened={Boolean(preview)}
onClose={() => setPreview(null)}
url={preview?.url ?? ''}
title={preview?.title}
/>
</Container>
);

View File

@@ -1288,4 +1288,49 @@ export const am: Translations = {
},
},
},
documents: {
title: 'ሰነዶቼ',
subtitle: 'EMA ያወጣልዎት እያንዳንዱ ሰነድ፣ እንዲሁም ከመዝገቦችዎ ጋር የተያያዙ ፋይሎች።',
tabs: {
license: 'ፈቃዶች',
medical: 'ሕክምና',
seaService: 'የባህር አገልግሎት',
personal: 'የግል መረጃ',
},
issuedTitle: 'በ EMA የተሰጡ ሰነዶች',
licensesTitle: 'የምስክር ወረቀቶች እና ፈቃዶች',
kind: {
SEAMAN_BOOK: 'የመርከበኛ መጽሐፍ',
BTC_BASIC_TRAINING: 'የመሠረታዊ ስልጠና የምስክር ወረቀት (BTC)',
},
documentStatus: {
AWAITING_REGISTRATION: 'ምዝገባ በመጠበቅ ላይ',
PAYMENT_PENDING: 'ክፍያ በመጠበቅ ላይ',
PAID: 'ተከፍሏል',
PAYMENT_CONFIRMED: 'ሰነድ በመዘጋጀት ላይ',
SCHEDULED: 'የመውሰጃ ቀጠሮ ተይዟል',
ISSUED: 'ተሰጥቷል',
REJECTED: 'ተቀባይነት አላገኘም',
CANCELLED: 'ተሰርዟል',
},
view: 'ይመልከቱ',
notIssued: 'እስካሁን አልተሰጠም',
openFailed: 'ሰነዱን መክፈት አልተቻለም',
files: {
none: 'ምንም የተያያዘ ፋይል የለም።',
},
empty: {
licenses: 'እስካሁን የተሰጠዎት የምስክር ወረቀት ወይም ፈቃድ የለም።',
medical: 'እስካሁን በመዝገብ ላይ የሕክምና የምስክር ወረቀት የለም።',
seaService: 'እስካሁን በመዝገብ ላይ የባህር አገልግሎት መዝገብ የለም።',
},
personal: {
description: 'ከመርከበኛ ምዝገባዎ ጋር ያስገቡት ሰነዶች።',
noRegistration: 'እስካሁን የመርከበኛ ምዝገባ ስለሌለዎት በመዝገብ ላይ የግል ሰነዶች የሉም።',
startRegistration: 'ወደ መርከበኛ ምዝገባ ይሂዱ',
uploaded: 'ተሰቅሏል',
missing: 'አልተሰቀለም',
},
},
};

View File

@@ -1290,6 +1290,53 @@ export const en = {
},
},
},
documents: {
title: 'My documents',
subtitle:
'Every document EMA has issued you, and the files attached to your records.',
tabs: {
license: 'Licences',
medical: 'Medical',
seaService: 'Sea Service',
personal: 'Personal Data',
},
issuedTitle: 'EMA-issued documents',
licensesTitle: 'Certificates and licences',
kind: {
SEAMAN_BOOK: 'Seaman Book',
BTC_BASIC_TRAINING: 'Basic Training Certificate (BTC)',
},
documentStatus: {
AWAITING_REGISTRATION: 'Awaiting registration',
PAYMENT_PENDING: 'Payment pending',
PAID: 'Paid',
PAYMENT_CONFIRMED: 'Preparing document',
SCHEDULED: 'Pickup scheduled',
ISSUED: 'Issued',
REJECTED: 'Rejected',
CANCELLED: 'Cancelled',
},
view: 'View',
notIssued: 'Not issued yet',
openFailed: 'Could not open the document',
files: {
none: 'No files attached.',
},
empty: {
licenses: 'No certificates or licences have been issued to you yet.',
medical: 'No medical certificates on file yet.',
seaService: 'No sea-service records on file yet.',
},
personal: {
description: 'The documents you submitted with your seafarer registration.',
noRegistration:
'You have no seafarer registration yet, so there are no personal documents on file.',
startRegistration: 'Go to seafarer registration',
uploaded: 'Uploaded',
missing: 'Not uploaded',
},
},
};
export type Translations = typeof en;