mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user