From 29f400618eb20526a5d71b198350f5c17380c835 Mon Sep 17 00:00:00 2001 From: fitse-yotor Date: Mon, 17 Aug 2026 06:57:36 +0300 Subject: [PATCH] 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 --- .../certificates/pages/CertificatesPage.tsx | 143 ++++++++++++------ .../endorsement/pages/EndorsementPage.tsx | 108 ++++++++----- 2 files changed, 174 insertions(+), 77 deletions(-) diff --git a/apps/portal/src/app/features/certificates/pages/CertificatesPage.tsx b/apps/portal/src/app/features/certificates/pages/CertificatesPage.tsx index b072a9ecd..5a1235ede 100644 --- a/apps/portal/src/app/features/certificates/pages/CertificatesPage.tsx +++ b/apps/portal/src/app/features/certificates/pages/CertificatesPage.tsx @@ -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 = { + 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 }).env?.['VITE_BASE_API_URL'] ?? @@ -99,6 +136,13 @@ export function CertificatesPage() { const [previewTitle, setPreviewTitle] = useState(''); const [loading, setLoading] = useState(false); + const { data } = useApiQuery({ + 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 */} My Applications - {MOCK_COC_APPS.length === 0 ? ( + {applications.length === 0 ? ( }> No active CoC/CoP applications. Click "Apply for CoC / CoP" to start. @@ -193,21 +237,34 @@ export function CertificatesPage() { - {MOCK_COC_APPS.map((app) => ( + {applications.map((app) => ( {app.id} {app.type} - {app.submitted} + {formatDate(app.submitted)} - {app.examDate - ? <>{app.examDate}{app.examVenue} - : {app.statusNote}} + + {humanStatus(app.status)} + - {app.status} + + {humanStatus(app.status)} + - Details + navigate(`/applications/${app.applicationId}`)} + > + Details + ))} @@ -219,13 +276,13 @@ export function CertificatesPage() { {/* Issued certificates */} My Certificates - {MOCK_CERTIFICATES.length === 0 ? ( + {certificates.length === 0 ? ( }> No certificates issued yet. ) : ( - {MOCK_CERTIFICATES.map((cert) => ( + {certificates.map((cert) => ( @@ -235,7 +292,7 @@ export function CertificatesPage() { {cert.id} - {cert.status} + {cert.status} diff --git a/apps/portal/src/app/features/endorsement/pages/EndorsementPage.tsx b/apps/portal/src/app/features/endorsement/pages/EndorsementPage.tsx index 0a26ebb7e..e42de8f64 100644 --- a/apps/portal/src/app/features/endorsement/pages/EndorsementPage.tsx +++ b/apps/portal/src/app/features/endorsement/pages/EndorsementPage.tsx @@ -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 = { + 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(null); + const { data } = useApiQuery({ + url: '/endorsements/my', + method: 'GET', + }); + const endorsementApps = data?.applications ?? []; + const issuedEndorsements = data?.issued ?? []; + if (applying) { return ( @@ -353,29 +393,29 @@ export function EndorsementPage() { {/* Active applications */} My Endorsement Applications - {MOCK_ENDORSEMENTS.length === 0 ? ( + {endorsementApps.length === 0 ? ( }> No active endorsement applications. ) : ( - {MOCK_ENDORSEMENTS.map((app) => ( + {endorsementApps.map((app) => (
{app.cocType} - CoC No: {app.foreignCocNo} · {app.issuingCountry} · Submitted {app.submitted} + CoC No: {app.foreignCocNo} · {app.issuingCountry} · Submitted {formatDate(app.submitted)}
- {app.status} + {humanStatus(app.status)} {app.id}
- } p="xs" mt="sm"> - {app.statusNote} + } p="xs" mt="sm"> + {humanStatus(app.status)}
))} @@ -386,13 +426,13 @@ export function EndorsementPage() { {/* Issued endorsements */} My Endorsements - {MOCK_ISSUED.length === 0 ? ( + {issuedEndorsements.length === 0 ? ( }> No endorsements issued yet. ) : ( - {MOCK_ISSUED.map((end) => ( + {issuedEndorsements.map((end) => ( @@ -402,7 +442,7 @@ export function EndorsementPage() { {end.endorsementNo} - {end.status} + {humanStatus(end.status)}