diff --git a/apps/portal/src/app/features/documents/pages/DocumentVaultPage.tsx b/apps/portal/src/app/features/documents/pages/DocumentVaultPage.tsx index 172419552..d7605054a 100644 --- a/apps/portal/src/app/features/documents/pages/DocumentVaultPage.tsx +++ b/apps/portal/src/app/features/documents/pages/DocumentVaultPage.tsx @@ -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 = { + 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 ; + if (files.length === 0) + return ( + + {t('documents.files.none')} + + ); + + return ( + + {files.map((file) => ( + + + {file.url ? ( + onPreview({ url: file.url as string, title: file.originalName })} + > + {file.originalName} + + ) : ( + {file.originalName} + )} + + ))} + + ); +} + +function FieldRow({ label, value }: { label: string; value: string }) { + return ( + + + {label} + + + {value} + + + ); +} + +/** 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 ( + + + + + + +
+ + {t(`documents.kind.${document.kind}`)} + + + {document.documentNumber ?? document.requestNumber} + +
+
+ + {t(`documents.documentStatus.${document.status}`, { defaultValue: document.status })} + +
+ + + + + {document.issueDate && ( + + )} + {document.expiryDate && ( + + )} + + + +
+ ); +} + export function DocumentVaultPage() { const { t } = useTranslation(); + const showDate = useDateDisplayer(); + const [preview, setPreview] = useState(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 ( - +
+ {t('documents.title')} + + {t('documents.subtitle')} + +
+ + + + }> + {t('documents.tabs.license')} + + }> + {t('documents.tabs.medical')} + + }> + {t('documents.tabs.seaService')} + + }> + {t('documents.tabs.personal')} + + + + {/* ── Licences and EMA-issued documents ───────────────────────── */} + + + {issued.length > 0 && ( +
+ + {t('documents.issuedTitle')} + + + {issued.map((document) => ( + + ))} + +
+ )} + +
+ + {t('documents.licensesTitle')} + + {loadingLicences ? ( + + ) : licenceItems.length === 0 ? ( + + {t('documents.empty.licenses')} + + ) : ( + + {licenceItems.map((licence) => ( + openCertificate(licence.id)} + onRenew={() => renewLicense(licence)} + /> + ))} + + )} +
+
+
+ + {/* ── Medical certificates ────────────────────────────────────── */} + + {loadingMedicals ? ( + + ) : (medicals ?? []).length === 0 ? ( + + {t('documents.empty.medical')} + + ) : ( + + {(medicals ?? []).map((record) => ( + + + + + + +
+ + {record.issuerName} + + {record.certificateNumber && ( + + {t('seaRecords.columns.certNumber', { + number: record.certificateNumber, + })} + + )} +
+
+ + {t(`seaRecords.columns.recordStatus.${record.status}`, { + defaultValue: record.status, + })} + +
+ + + + + + + + + + + +
+ ))} +
+ )} +
+ + {/* ── Sea service records ─────────────────────────────────────── */} + + {loadingSeaService ? ( + + ) : (seaService ?? []).length === 0 ? ( + + {t('documents.empty.seaService')} + + ) : ( + + {(seaService ?? []).map((record) => ( + + + + + + +
+ + {record.vesselName} + + {record.imoNumber && ( + + {t('seaRecords.columns.imo', { number: record.imoNumber })} + + )} +
+
+ + {t(`seaRecords.columns.recordStatus.${record.status}`, { + defaultValue: record.status, + })} + +
+ + + + + + + + + + + +
+ ))} +
+ )} +
+ + {/* ── Personal documents from the seafarer registration ───────── */} + + {loadingRegistration ? ( + + ) : !registration ? ( + // Personal documents are uploaded against a registration, so + // without one there is nothing to show — and nowhere to put an + // upload either. + }> + + {t('documents.personal.noRegistration')} + + + + ) : ( + + + {t('documents.personal.description')} + + + {documentSlots( + Boolean(registration.passportNumber), + registration.nationality, + ).map((slot) => { + const attachment = (registrationFiles ?? []).find( + (a) => a.documentKey === slot.key, + ); + const files = attachment?.files ?? []; + return ( + + +
+ + {slot.name} + + {slot.description && ( + + {slot.description} + + )} +
+ + {files.length + ? t('documents.personal.uploaded') + : t('documents.personal.missing')} + +
+ + + + {files.length === 0 ? ( + + {t('documents.files.none')} + + ) : ( + + {files.map((file) => ( + + + {file.url ? ( + + setPreview({ + url: file.url as string, + title: file.originalName, + }) + } + > + {file.originalName} + + ) : ( + {file.originalName} + )} + + ))} + + )} +
+ ); + })} +
+
+ )} +
+
+ + + setPreview(null)} + url={preview?.url ?? ''} + title={preview?.title} />
); diff --git a/apps/portal/src/app/i18n/locales/am.ts b/apps/portal/src/app/i18n/locales/am.ts index 2a56acf6c..6d9d452a6 100644 --- a/apps/portal/src/app/i18n/locales/am.ts +++ b/apps/portal/src/app/i18n/locales/am.ts @@ -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: 'አልተሰቀለም', + }, + }, }; diff --git a/apps/portal/src/app/i18n/locales/en.ts b/apps/portal/src/app/i18n/locales/en.ts index a181d2ead..209a5e8b6 100644 --- a/apps/portal/src/app/i18n/locales/en.ts +++ b/apps/portal/src/app/i18n/locales/en.ts @@ -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;