mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
- Added useLocalized hook to provide a stable function for retrieving bilingual values based on the current language. - Updated various components across the portal and backoffice to utilize the new useLocalized hook for consistent bilingual label rendering. - Refactored localized function in licensing.helpers to handle empty Amharic strings correctly. - Enhanced localization handling in LicenseApplicationPage, ProfilePage, and other components to ensure proper language switching.
284 lines
8.7 KiB
TypeScript
284 lines
8.7 KiB
TypeScript
import {
|
|
Alert,
|
|
Badge,
|
|
Button,
|
|
Card,
|
|
Group,
|
|
List,
|
|
Loader,
|
|
Stack,
|
|
Table,
|
|
Text,
|
|
ThemeIcon,
|
|
Title,
|
|
} from '@mantine/core';
|
|
import {
|
|
IconArrowRight,
|
|
IconCertificate,
|
|
IconCircleCheck,
|
|
IconCircleX,
|
|
IconInfoCircle,
|
|
} from '@tabler/icons-react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import {
|
|
STATUS_COLORS,
|
|
STATUS_LABELS,
|
|
TERMINAL_STATUSES,
|
|
extractErrorMessage,
|
|
useLocalized,
|
|
useGetCertificateUrlMutation,
|
|
useGetMyApplicationsQuery,
|
|
useGetMyLicensesQuery,
|
|
useGetMyMedicalCertificatesQuery,
|
|
useGetMySeaTimeQuery,
|
|
} from '@ema-platform/api';
|
|
import { useCurrentProfile } from '@ema-platform/auth';
|
|
import { notify } from '@ema-platform/ui';
|
|
import { useDateDisplayer } from '@ema-platform/shared';
|
|
|
|
const CERTIFICATE_TYPE_KEYS = [
|
|
'CERTIFICATE_OF_COMPETENCY',
|
|
'CERTIFICATE_OF_PROFICIENCY',
|
|
];
|
|
|
|
function EligibilityItem({ ok, label }: { ok: boolean; label: string }) {
|
|
return (
|
|
<List.Item
|
|
icon={
|
|
<ThemeIcon
|
|
color={ok ? 'teal' : 'red'}
|
|
variant="light"
|
|
size="sm"
|
|
radius="xl"
|
|
>
|
|
{ok ? <IconCircleCheck size={14} /> : <IconCircleX size={14} />}
|
|
</ThemeIcon>
|
|
}
|
|
>
|
|
{label}
|
|
</List.Item>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* CoC / CoP home (US-CERT-002…004, 009): eligibility at a glance, the two
|
|
* application entry points, and the seafarer's certificate applications and
|
|
* issued certificates. The wizard itself is the config-driven licensing flow.
|
|
*/
|
|
export function CertificatesPage() {
|
|
const navigate = useNavigate();
|
|
const { profile, isLoading: loadingProfile } = useCurrentProfile();
|
|
const { data: seaTime } = useGetMySeaTimeQuery();
|
|
const { data: medicals } = useGetMyMedicalCertificatesQuery();
|
|
const { data: applications, isLoading: loadingApplications } =
|
|
useGetMyApplicationsQuery();
|
|
const { data: licenses } = useGetMyLicensesQuery();
|
|
const [getCertificateUrl] = useGetCertificateUrlMutation();
|
|
const showDate = useDateDisplayer();
|
|
const localized = useLocalized();
|
|
|
|
const registered =
|
|
Boolean(profile?.seafarerNumber) && profile?.seafarerStatus === 'ACTIVE';
|
|
const today = new Date().toISOString().slice(0, 10);
|
|
const hasMedical = (medicals ?? []).some(
|
|
(certificate) =>
|
|
certificate.status !== 'REJECTED' && certificate.expiryDate >= today,
|
|
);
|
|
const verifiedDays = seaTime?.totalDays ?? 0;
|
|
|
|
const certificateApplications = (applications?.items ?? []).filter((app) =>
|
|
CERTIFICATE_TYPE_KEYS.includes(app.licenseType?.key ?? ''),
|
|
);
|
|
const inFlight = certificateApplications.filter(
|
|
(app) => !TERMINAL_STATUSES.includes(app.status),
|
|
);
|
|
const issued = (licenses?.items ?? []).filter((license) =>
|
|
CERTIFICATE_TYPE_KEYS.includes(license.licenseType?.key ?? ''),
|
|
);
|
|
|
|
async function download(licenseId: string) {
|
|
try {
|
|
const result = await getCertificateUrl(licenseId).unwrap();
|
|
window.open(result.url, '_blank', 'noopener');
|
|
} catch (error) {
|
|
notify.error(extractErrorMessage(error, 'Could not fetch certificate'));
|
|
}
|
|
}
|
|
|
|
if (loadingProfile || loadingApplications) {
|
|
return (
|
|
<Group justify="center" py="xl">
|
|
<Loader />
|
|
</Group>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Stack maw={860} mx="auto">
|
|
<Title order={2}>My Certificates</Title>
|
|
|
|
<Card withBorder radius="md" p="lg">
|
|
<Group justify="space-between" align="flex-start">
|
|
<div>
|
|
<Text fw={600} mb={6}>
|
|
Eligibility
|
|
</Text>
|
|
<List spacing={4} size="sm">
|
|
<EligibilityItem
|
|
ok={registered}
|
|
label={
|
|
registered
|
|
? `Registered seafarer (${profile?.seafarerNumber})`
|
|
: 'Active seafarer registration required'
|
|
}
|
|
/>
|
|
<EligibilityItem
|
|
ok={hasMedical}
|
|
label={
|
|
hasMedical
|
|
? 'Current medical certificate on file'
|
|
: 'A current medical certificate is required'
|
|
}
|
|
/>
|
|
<EligibilityItem
|
|
ok={verifiedDays > 0}
|
|
label={`Verified sea time: ${verifiedDays} days (CoC needs 360, CoP 90)`}
|
|
/>
|
|
</List>
|
|
</div>
|
|
<Stack gap="xs">
|
|
<Button
|
|
rightSection={<IconArrowRight size={16} />}
|
|
onClick={() =>
|
|
navigate('/licensing/CERTIFICATE_OF_COMPETENCY/apply')
|
|
}
|
|
>
|
|
Apply for CoC
|
|
</Button>
|
|
<Button
|
|
variant="light"
|
|
rightSection={<IconArrowRight size={16} />}
|
|
onClick={() =>
|
|
navigate('/licensing/CERTIFICATE_OF_PROFICIENCY/apply')
|
|
}
|
|
>
|
|
Apply for CoP
|
|
</Button>
|
|
</Stack>
|
|
</Group>
|
|
{!registered && (
|
|
<Alert color="orange" mt="md" icon={<IconInfoCircle size={16} />}>
|
|
Complete your{' '}
|
|
<Text
|
|
component="span"
|
|
c="blue"
|
|
style={{ cursor: 'pointer' }}
|
|
onClick={() => navigate('/seafarer-registration')}
|
|
>
|
|
seafarer registration
|
|
</Text>{' '}
|
|
first — certificate applications are refused without it.
|
|
</Alert>
|
|
)}
|
|
</Card>
|
|
|
|
{inFlight.length > 0 && (
|
|
<Stack gap="xs">
|
|
<Title order={4}>Applications in progress</Title>
|
|
{inFlight.map((app) => (
|
|
<Card key={app.id} withBorder radius="md" p="md">
|
|
<Group justify="space-between">
|
|
<div>
|
|
<Text fw={600}>{app.applicationNumber}</Text>
|
|
<Text size="xs" c="dimmed">
|
|
{localized(app.licenseType?.name)}
|
|
</Text>
|
|
</div>
|
|
<Group>
|
|
<Badge color={STATUS_COLORS[app.status]}>
|
|
{STATUS_LABELS[app.status]}
|
|
</Badge>
|
|
<Button
|
|
size="compact-sm"
|
|
variant="light"
|
|
onClick={() =>
|
|
navigate(
|
|
`/licensing/${app.licenseType?.key}/applications/${app.id}`,
|
|
)
|
|
}
|
|
>
|
|
{app.status === 'DRAFT' || app.status === 'RESUBMIT_REQUIRED'
|
|
? 'Continue'
|
|
: 'View'}
|
|
</Button>
|
|
</Group>
|
|
</Group>
|
|
</Card>
|
|
))}
|
|
</Stack>
|
|
)}
|
|
|
|
<Stack gap="xs">
|
|
<Title order={4}>Issued certificates</Title>
|
|
{issued.length === 0 ? (
|
|
<Card withBorder radius="md" p="lg">
|
|
<Text size="sm" c="dimmed" ta="center">
|
|
No certificates issued yet.
|
|
</Text>
|
|
</Card>
|
|
) : (
|
|
<Table.ScrollContainer minWidth={640}>
|
|
<Table striped>
|
|
<Table.Thead>
|
|
<Table.Tr>
|
|
<Table.Th>Certificate №</Table.Th>
|
|
<Table.Th>Type</Table.Th>
|
|
<Table.Th>Issued</Table.Th>
|
|
<Table.Th>Expires</Table.Th>
|
|
<Table.Th>Status</Table.Th>
|
|
<Table.Th />
|
|
</Table.Tr>
|
|
</Table.Thead>
|
|
<Table.Tbody>
|
|
{issued.map((license) => (
|
|
<Table.Tr key={license.id}>
|
|
<Table.Td>
|
|
<Text ff="monospace" size="sm" fw={600}>
|
|
{license.certificateNumber}
|
|
</Text>
|
|
</Table.Td>
|
|
<Table.Td>{localized(license.licenseType?.name)}</Table.Td>
|
|
<Table.Td>{showDate(license.issueDate)}</Table.Td>
|
|
<Table.Td>{showDate(license.expiryDate)}</Table.Td>
|
|
<Table.Td>
|
|
<Badge
|
|
size="sm"
|
|
variant="light"
|
|
color={license.status === 'ACTIVE' ? 'green' : 'red'}
|
|
>
|
|
{license.status}
|
|
</Badge>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<Button
|
|
size="compact-xs"
|
|
variant="subtle"
|
|
leftSection={<IconCertificate size={14} />}
|
|
onClick={() => download(license.id)}
|
|
>
|
|
Download
|
|
</Button>
|
|
</Table.Td>
|
|
</Table.Tr>
|
|
))}
|
|
</Table.Tbody>
|
|
</Table>
|
|
</Table.ScrollContainer>
|
|
)}
|
|
</Stack>
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
export default CertificatesPage;
|