feat(portal): wire the certificates and endorsement screens to the API

Both listed hardcoded samples -- a CoC application permanently awaiting
examination, an endorsement from Greece. They now show the seafarer's
own certificates and applications.

Status badges are keyed by the workflow's own values rather than display
strings, so a status the map has not seen falls back to grey instead of
rendering colourless, and the label is derived from the status itself
rather than stored beside it where the two can disagree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
fitse-yotor
2026-08-17 06:57:36 +03:00
parent 35c7987047
commit 29f400618e
2 changed files with 174 additions and 77 deletions

View File

@@ -30,43 +30,80 @@ import {
IconShieldCheck,
} from '@tabler/icons-react';
import { authStorage } from '@ema-platform/auth';
import { useApiQuery } from '@ema-platform/api';
// ---------------------------------------------------------------------------
// Mock data
// ---------------------------------------------------------------------------
const MOCK_COC_APPS = [
{
id: 'COC-APP-2025-001',
type: 'CoC — STCW II/1 Officer in Charge of Navigational Watch',
submitted: '2025-03-10',
examDate: '2025-04-15',
examVenue: 'EMA HQ — Addis Ababa',
status: 'Examination Scheduled',
statusColor: 'indigo',
statusNote: 'TRB inspected and approved by EMA officer. Attend your scheduled examination.',
},
{
id: 'COC-APP-2025-005',
type: 'CoC — STCW II/5 Able Seafarer Deck (AB)',
submitted: '2025-05-01',
examDate: null,
examVenue: null,
status: 'TRB Inspection',
statusColor: 'yellow',
statusNote: 'Your TRB is being physically inspected by an EMA officer. You may be contacted to bring the original document.',
},
];
/** What `/certificates/my` returns: what is held, and what is still in flight. */
interface CertificatesOverview {
certificates: {
id: string;
type: string;
issued: string;
expiry: string;
status: string;
}[];
applications: {
id: string;
applicationId: string;
type: string;
submitted: string;
status: string;
}[];
}
const MOCK_CERTIFICATES = [
{
id: 'COC-2023-0042',
type: 'CoC — STCW II/1',
issued: '2023-06-20',
expiry: '2028-06-20',
status: 'Valid',
statusColor: 'teal',
},
];
/**
* Badge colour per workflow status.
*
* Keyed by the values the API reports rather than display strings, so an
* unmapped status falls back to grey instead of rendering colourless.
*/
const STATUS_COLOR: Record<string, string> = {
DRAFT: 'gray',
SUBMITTED: 'blue',
UNDER_REVIEW: 'yellow',
UNDER_EVALUATION: 'yellow',
RESUBMIT_REQUIRED: 'orange',
INSPECTION_PENDING: 'grape',
INSPECTION_COMPLETED: 'grape',
ELIGIBILITY_APPROVED: 'teal',
EXAM_PAYMENT_PENDING: 'orange',
EXAM_PAID: 'blue',
EXAM_SCHEDULED: 'indigo',
EXAM_PASSED: 'teal',
EXAM_FAILED: 'red',
APPROVED: 'teal',
REJECTED: 'red',
PAYMENT_PENDING: 'orange',
PAID: 'blue',
PAYMENT_CONFIRMED: 'blue',
CERTIFICATE_ISSUED: 'teal',
COMPLETED: 'teal',
ACTIVE: 'teal',
EXPIRED: 'red',
SUSPENDED: 'orange',
CANCELLED: 'gray',
SUPERSEDED: 'gray',
};
/** Turns `EXAM_PAYMENT_PENDING` into something a person reads. */
function humanStatus(status: string): string {
return status
.toLowerCase()
.split('_')
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ');
}
function formatDate(value: string | null | undefined): string {
if (!value) return '—';
return new Date(value).toLocaleDateString('en-GB', {
day: '2-digit',
month: 'short',
year: 'numeric',
});
}
const API_BASE =
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
@@ -99,6 +136,13 @@ export function CertificatesPage() {
const [previewTitle, setPreviewTitle] = useState('');
const [loading, setLoading] = useState(false);
const { data } = useApiQuery<CertificatesOverview>({
url: '/certificates/my',
method: 'GET',
});
const certificates = data?.certificates ?? [];
const applications = data?.applications ?? [];
const openPreview = async (profileId: string, title: string) => {
setLoading(true);
try {
@@ -179,7 +223,7 @@ export function CertificatesPage() {
{/* Active applications */}
<Paper withBorder radius="lg" p="xl">
<Text fw={700} mb="md">My Applications</Text>
{MOCK_COC_APPS.length === 0 ? (
{applications.length === 0 ? (
<Alert variant="light" color="gray" icon={<IconInfoCircle size={15} />}>
No active CoC/CoP applications. Click "Apply for CoC / CoP" to start.
</Alert>
@@ -193,21 +237,34 @@ export function CertificatesPage() {
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{MOCK_COC_APPS.map((app) => (
{applications.map((app) => (
<Table.Tr key={app.id}>
<Table.Td><Text fz="xs" fw={600} c="blue.7">{app.id}</Text></Table.Td>
<Table.Td><Text fz="xs" fw={500} maw={220} style={{ lineHeight: 1.3 }}>{app.type}</Text></Table.Td>
<Table.Td><Text fz="xs">{app.submitted}</Text></Table.Td>
<Table.Td><Text fz="xs">{formatDate(app.submitted)}</Text></Table.Td>
<Table.Td>
{app.examDate
? <><Text fz="xs" fw={500}>{app.examDate}</Text><Text fz="xs" c="dimmed">{app.examVenue}</Text></>
: <Text fz="xs" c="dimmed" maw={200} lh={1.3}>{app.statusNote}</Text>}
<Text fz="xs" c="dimmed" maw={200} lh={1.3}>
{humanStatus(app.status)}
</Text>
</Table.Td>
<Table.Td>
<Badge color={app.statusColor} variant="light" size="sm">{app.status}</Badge>
<Badge
color={STATUS_COLOR[app.status] ?? 'gray'}
variant="light"
size="sm"
>
{humanStatus(app.status)}
</Badge>
</Table.Td>
<Table.Td>
<Text fz="xs" c="blue" style={{ cursor: 'pointer' }}>Details</Text>
<Text
fz="xs"
c="blue"
style={{ cursor: 'pointer' }}
onClick={() => navigate(`/applications/${app.applicationId}`)}
>
Details
</Text>
</Table.Td>
</Table.Tr>
))}
@@ -219,13 +276,13 @@ export function CertificatesPage() {
{/* Issued certificates */}
<Paper withBorder radius="lg" p="xl">
<Text fw={700} mb="md">My Certificates</Text>
{MOCK_CERTIFICATES.length === 0 ? (
{certificates.length === 0 ? (
<Alert variant="light" color="gray" icon={<IconInfoCircle size={15} />}>
No certificates issued yet.
</Alert>
) : (
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{MOCK_CERTIFICATES.map((cert) => (
{certificates.map((cert) => (
<Card key={cert.id} withBorder radius="md" p="md">
<Group justify="space-between" mb="sm">
<Group gap="sm">
@@ -235,7 +292,7 @@ export function CertificatesPage() {
<Text fz="xs" c="dimmed">{cert.id}</Text>
</div>
</Group>
<Badge color={cert.statusColor} variant="light">{cert.status}</Badge>
<Badge color={STATUS_COLOR[cert.status] ?? "gray"} variant="light">{cert.status}</Badge>
</Group>
<Divider mb="sm" />
<SimpleGrid cols={2} spacing="xs">