mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-28 21:50:58 +00:00
revert
This commit is contained in:
@@ -1,561 +1,21 @@
|
||||
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 { Container } from '@mantine/core';
|
||||
import { FeatureUnavailable } from '@ema-platform/ui';
|
||||
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',
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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 })}
|
||||
>
|
||||
{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 { 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">
|
||||
<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>
|
||||
|
||||
{/* ── 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'}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{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}
|
||||
<FeatureUnavailable
|
||||
title={t('featureUnavailable.documents.title')}
|
||||
description={t('featureUnavailable.documents.description')}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user