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">

View File

@@ -1,4 +1,5 @@
import { useState } from 'react';
import { useApiQuery } from '@ema-platform/api';
import { useNavigate } from 'react-router-dom';
import {
Alert,
@@ -39,32 +40,64 @@ import {
// ---------------------------------------------------------------------------
// Mock data — existing endorsement applications
// ---------------------------------------------------------------------------
const MOCK_ENDORSEMENTS = [
{
id: 'END-APP-2025-001',
cocType: 'Officer in Charge of a Navigational Watch (STCW II/1)',
foreignCocNo: 'PHL-COC-2022-0045',
issuingCountry: 'Philippines',
submitted: '2025-04-05',
status: 'Document Verification',
statusColor: 'blue',
statusNote: 'EMA is verifying your documents. You will be notified when verification is complete.',
},
];
/** What `/endorsements/my` returns. */
interface EndorsementsOverview {
issued: {
id: string;
endorsementNo: string;
cocType: string;
foreignCocNo: string;
issuingCountry: string;
issued: string;
expiry: string;
status: string;
}[];
applications: {
id: string;
applicationId: string;
cocType: string;
foreignCocNo: string;
issuingCountry: string;
submitted: string;
status: string;
}[];
}
const MOCK_ISSUED = [
{
id: 'EMA-END-2024-012',
cocType: 'Chief Mate — STCW II/2',
foreignCocNo: 'GRC-COC-2019-0033',
issuingCountry: 'Greece',
endorsementNo: 'EMA-END-2024-012',
issued: '2024-08-10',
expiry: '2029-06-15',
status: 'Valid',
statusColor: 'teal',
},
];
// Keyed by the workflow's own status values so an unmapped one falls back to
// grey rather than rendering colourless.
const STATUS_COLOR: Record<string, string> = {
DRAFT: 'gray',
SUBMITTED: 'blue',
UNDER_REVIEW: 'yellow',
UNDER_EVALUATION: 'yellow',
RESUBMIT_REQUIRED: 'orange',
APPROVED: 'teal',
REJECTED: 'red',
PAYMENT_PENDING: 'orange',
PAYMENT_CONFIRMED: 'blue',
CERTIFICATE_ISSUED: 'teal',
COMPLETED: 'teal',
ACTIVE: 'teal',
EXPIRED: 'red',
SUSPENDED: 'orange',
};
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',
});
}
// blank PDF
const BLANK_PDF = 'data:application/pdf;base64,JVBERi0xLjQKJcfsj6IKMSAwIG9iago8PC9UeXBlL0NhdGFsb2cvUGFnZXMgMiAwIFI+PgplbmRvYmoKMiAwIG9iago8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PgplbmRvYmoKMyAwIG9iago8PC9UeXBlL1BhZ2UvUGFyZW50IDIgMCBSL01lZGlhQm94WzAgMCA2MTIgNzkyXT4+CmVuZG9iagp4cmVmCjAgNAowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMDkgMDAwMDAgbiAKMDAwMDAwMDA1OCAwMDAwMCBuIAowMDAwMDAwMTE1IDAwMDAwIG4gCnRyYWlsZXIKPDwvU2l6ZSA0L1Jvb3QgMSAwIFI+PgpzdGFydHhyZWYKMjE3CiUlRU9G';
@@ -298,6 +331,13 @@ export function EndorsementPage() {
const [applying, setApplying] = useState(false);
const [previewId, setPreviewId] = useState<string | null>(null);
const { data } = useApiQuery<EndorsementsOverview>({
url: '/endorsements/my',
method: 'GET',
});
const endorsementApps = data?.applications ?? [];
const issuedEndorsements = data?.issued ?? [];
if (applying) {
return (
<Stack gap="md">
@@ -353,29 +393,29 @@ export function EndorsementPage() {
{/* Active applications */}
<Paper withBorder radius="lg" p="xl">
<Text fw={700} mb="md">My Endorsement Applications</Text>
{MOCK_ENDORSEMENTS.length === 0 ? (
{endorsementApps.length === 0 ? (
<Alert variant="light" color="gray" icon={<IconInfoCircle size={15} />}>
No active endorsement applications.
</Alert>
) : (
<Stack gap="sm">
{MOCK_ENDORSEMENTS.map((app) => (
{endorsementApps.map((app) => (
<Paper key={app.id} withBorder radius="md" p="md">
<Group justify="space-between" wrap="wrap" gap="sm">
<Group gap="sm" wrap="nowrap">
<ThemeIcon size={36} radius="md" color="blue" variant="light"><IconRubberStamp size={18} /></ThemeIcon>
<div>
<Text fz="sm" fw={700}>{app.cocType}</Text>
<Text fz="xs" c="dimmed">CoC No: {app.foreignCocNo} · {app.issuingCountry} · Submitted {app.submitted}</Text>
<Text fz="xs" c="dimmed">CoC No: {app.foreignCocNo} · {app.issuingCountry} · Submitted {formatDate(app.submitted)}</Text>
</div>
</Group>
<Group gap="xs">
<Badge color={app.statusColor} variant="light">{app.status}</Badge>
<Badge color={STATUS_COLOR[app.status] ?? "gray"} variant="light">{humanStatus(app.status)}</Badge>
<Text fz="xs" c="blue.7" fw={600}>{app.id}</Text>
</Group>
</Group>
<Alert variant="light" color={app.statusColor} icon={<IconInfoCircle size={13} />} p="xs" mt="sm">
<Text fz="xs">{app.statusNote}</Text>
<Alert variant="light" color={STATUS_COLOR[app.status] ?? "gray"} icon={<IconInfoCircle size={13} />} p="xs" mt="sm">
<Text fz="xs">{humanStatus(app.status)}</Text>
</Alert>
</Paper>
))}
@@ -386,13 +426,13 @@ export function EndorsementPage() {
{/* Issued endorsements */}
<Paper withBorder radius="lg" p="xl">
<Text fw={700} mb="md">My Endorsements</Text>
{MOCK_ISSUED.length === 0 ? (
{issuedEndorsements.length === 0 ? (
<Alert variant="light" color="gray" icon={<IconInfoCircle size={15} />}>
No endorsements issued yet.
</Alert>
) : (
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{MOCK_ISSUED.map((end) => (
{issuedEndorsements.map((end) => (
<Card key={end.id} withBorder radius="md" p="md">
<Group justify="space-between" mb="sm">
<Group gap="sm">
@@ -402,7 +442,7 @@ export function EndorsementPage() {
<Text fz="xs" c="dimmed">{end.endorsementNo}</Text>
</div>
</Group>
<Badge color={end.statusColor} variant="light">{end.status}</Badge>
<Badge color={STATUS_COLOR[end.status] ?? "gray"} variant="light">{humanStatus(end.status)}</Badge>
</Group>
<Divider mb="sm" />
<SimpleGrid cols={2} spacing="xs" mb="sm">