mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-08 19:28:18 +00:00
462 lines
16 KiB
TypeScript
462 lines
16 KiB
TypeScript
import { useState } from 'react';
|
|
import {
|
|
Anchor,
|
|
Badge,
|
|
Button,
|
|
Card,
|
|
Container,
|
|
Divider,
|
|
Group,
|
|
Loader,
|
|
SimpleGrid,
|
|
Stack,
|
|
Tabs,
|
|
Text,
|
|
ThemeIcon,
|
|
Title,
|
|
} from '@mantine/core';
|
|
import {
|
|
IconAnchor,
|
|
IconCertificate,
|
|
IconEye,
|
|
IconHeartbeat,
|
|
IconIdBadge2,
|
|
IconPaperclip,
|
|
} from '@tabler/icons-react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useDateDisplayer } from '@ema-platform/shared';
|
|
import { FilePreviewModal, notify } from '@ema-platform/ui';
|
|
import {
|
|
extractErrorMessage,
|
|
useGetAttachmentsQuery,
|
|
useGetCertificateUrlMutation,
|
|
useGetMyLicensesQuery,
|
|
useGetMyMedicalCertificatesQuery,
|
|
useGetMySeaServiceRecordsQuery,
|
|
useGetMySeafarerDocumentsQuery,
|
|
useLazyGetMySeafarerDocumentDownloadQuery,
|
|
type SeafarerDocument,
|
|
type SeafarerRecordStatus,
|
|
} from '@ema-platform/api';
|
|
import { LicenseCard, useRenewLicense } from '../../licensing/components/LicenseCard';
|
|
import { PersonalDocumentSlots } from '../components/PersonalDocumentSlots';
|
|
|
|
/** What the viewer needs: the link, a caption, and how to render it. */
|
|
type Preview = { url: string; title: string; mimeType?: string | null };
|
|
|
|
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.
|
|
*/
|
|
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,
|
|
mimeType: file.mimeType,
|
|
})
|
|
}
|
|
>
|
|
{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'} style={{ flexShrink: 0 }}>
|
|
{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 [getCertificateUrl, { isLoading: isDownloadingCert }] = useGetCertificateUrlMutation();
|
|
const { renewLicense, isRenewing } = useRenewLicense();
|
|
|
|
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">
|
|
<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>
|
|
) : (
|
|
// Two per row, not three: licence type names run long
|
|
// ("Multimodal Transport Operator License") and a third
|
|
// column truncates them.
|
|
<SimpleGrid cols={{ base: 1, md: 2 }} 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]}
|
|
style={{ flexShrink: 0 }}
|
|
>
|
|
{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]}
|
|
style={{ flexShrink: 0 }}
|
|
>
|
|
{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>
|
|
|
|
{/* ── The applicant's own document vault ──────────────────────── */}
|
|
<Tabs.Panel value="personal">
|
|
{/* Owned by the profile, not by a registration or an application:
|
|
the slots are configured in the backoffice and the files travel
|
|
with the person. */}
|
|
<PersonalDocumentSlots onPreview={setPreview} />
|
|
</Tabs.Panel>
|
|
</Tabs>
|
|
</Stack>
|
|
|
|
<FilePreviewModal
|
|
opened={Boolean(preview)}
|
|
onClose={() => setPreview(null)}
|
|
url={preview?.url ?? ''}
|
|
title={preview?.title}
|
|
mimeType={preview?.mimeType}
|
|
labels={{
|
|
unsupported: t('documents.preview.unsupported'),
|
|
openInNewTab: t('documents.preview.openInNewTab'),
|
|
close: t('documents.preview.close'),
|
|
}}
|
|
/>
|
|
</Container>
|
|
);
|
|
}
|
|
|
|
export default DocumentVaultPage;
|