ui componenet based on the requirements

This commit is contained in:
mengstabketemaw
2026-06-20 09:44:41 +03:00
parent 1f27a36b90
commit 5d8e749cf7
28 changed files with 8652 additions and 41 deletions

View File

@@ -0,0 +1,338 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
ActionIcon,
Badge,
Box,
Button,
Card,
Group,
Paper,
Select,
SimpleGrid,
Stack,
Table,
Text,
TextInput,
ThemeIcon,
Title,
rem,
} from '@mantine/core';
import {
IconBook2,
IconCheck,
IconCircleCheck,
IconClock,
IconEye,
IconFileDescription,
IconSearch,
IconShieldCheck,
IconX,
} from '@tabler/icons-react';
export type CoCAppStatus =
| 'Submitted'
| 'Document Review'
| 'TRB Inspection'
| 'TRB Resubmit Required'
| 'TRB Rejected'
| 'Examination Scheduled'
| 'Examination Passed'
| 'Examination Failed'
| 'Certificate Issued';
export interface CoCApp {
id: string;
seafarerId: string;
name: string;
email: string;
mobile: string;
nationality: string;
certType: string;
stcwRef: string;
level: 'Support' | 'Operational' | 'Management';
dept: string;
submitted: string;
paymentStatus: 'Verified' | 'Pending';
status: CoCAppStatus;
examDate: string | null;
examVenue: string | null;
seaServiceFile: string;
trbFile: string;
competencyDocs: { area: string; uploaded: boolean; filename: string }[];
trbInspectionNotes: string;
examNotes: string;
officerRemarks: string;
}
export const STATUS_COLOR: Record<CoCAppStatus, string> = {
'Submitted': 'gray',
'Document Review': 'blue',
'TRB Inspection': 'yellow',
'TRB Resubmit Required': 'orange',
'TRB Rejected': 'red',
'Examination Scheduled': 'indigo',
'Examination Passed': 'teal',
'Examination Failed': 'red',
'Certificate Issued': 'teal',
};
export const MOCK_COC_APPS: CoCApp[] = [
{
id: 'COC-APP-2025-001',
seafarerId: 'SF-2024-0001',
name: 'Abebe Girma',
email: 'abebe.g@email.com',
mobile: '+251 911 234 567',
nationality: 'Ethiopian',
certType: 'Officer in Charge of a Navigational Watch (OICNW)',
stcwRef: 'Reg. II/1 — Code A-II/1',
level: 'Operational',
dept: 'Deck',
submitted: '2025-03-10',
paymentStatus: 'Verified',
status: 'TRB Inspection',
examDate: null,
examVenue: null,
seaServiceFile: 'abebe_sea_service.pdf',
trbFile: 'abebe_trb.pdf',
competencyDocs: [
{ area: 'Navigation at Operational Level', uploaded: true, filename: 'abebe_nav.pdf' },
{ area: 'Cargo Handling & Stowage', uploaded: true, filename: 'abebe_cargo.pdf' },
{ area: 'Control of Ship Operations', uploaded: true, filename: 'abebe_ops.pdf' },
{ area: 'Radio Communication (GMDSS)', uploaded: true, filename: 'abebe_gmdss.pdf' },
{ area: 'Leadership & Teamwork', uploaded: true, filename: 'abebe_brm.pdf' },
],
trbInspectionNotes: '',
examNotes: '',
officerRemarks: '',
},
{
id: 'COC-APP-2025-002',
seafarerId: 'SF-2024-0002',
name: 'Sara Tadesse',
email: 'sara.t@email.com',
mobile: '+251 922 345 678',
nationality: 'Ethiopian',
certType: 'Able Seafarer Deck (AB)',
stcwRef: 'Reg. II/5 — Code A-II/5',
level: 'Support',
dept: 'Deck',
submitted: '2025-03-18',
paymentStatus: 'Verified',
status: 'Document Review',
examDate: null,
examVenue: null,
seaServiceFile: 'sara_sea_service.pdf',
trbFile: '',
competencyDocs: [
{ area: 'Navigation', uploaded: true, filename: 'sara_nav.pdf' },
{ area: 'Cargo Operations', uploaded: true, filename: 'sara_cargo.pdf' },
{ area: 'Ship Operations', uploaded: false, filename: '' },
{ area: 'Safety & Emergency', uploaded: true, filename: 'sara_safety.pdf' },
],
trbInspectionNotes: '',
examNotes: '',
officerRemarks: 'Missing Ship Operations certificate. Requested resubmission.',
},
{
id: 'COC-APP-2025-003',
seafarerId: 'SF-2024-0003',
name: 'Dawit Bekele',
email: 'dawit.b@email.com',
mobile: '+251 933 456 789',
nationality: 'Ethiopian',
certType: 'Officer in Charge of an Engineering Watch (OICEW)',
stcwRef: 'Reg. III/1 — Code A-III/1',
level: 'Operational',
dept: 'Engine',
submitted: '2025-03-25',
paymentStatus: 'Verified',
status: 'Examination Scheduled',
examDate: '2025-05-10',
examVenue: 'EMA HQ — Addis Ababa',
seaServiceFile: 'dawit_sea_service.pdf',
trbFile: 'dawit_trb.pdf',
competencyDocs: [
{ area: 'Marine Engineering at Operational Level', uploaded: true, filename: 'dawit_eng.pdf' },
{ area: 'Electrical & Control Systems', uploaded: true, filename: 'dawit_elec.pdf' },
{ area: 'Maintenance & Repair', uploaded: true, filename: 'dawit_maint.pdf' },
{ area: 'Controlling Ship Operations', uploaded: true, filename: 'dawit_ops.pdf' },
{ area: 'Leadership & Teamwork', uploaded: true, filename: 'dawit_brm.pdf' },
],
trbInspectionNotes: 'TRB physically inspected on 2025-04-20. All competency entries signed off by approved assessor.',
examNotes: '',
officerRemarks: '',
},
{
id: 'COC-APP-2025-004',
seafarerId: 'SF-2023-0088',
name: 'Tekle Haile',
email: 'tekle.h@email.com',
mobile: '+251 944 567 890',
nationality: 'Ethiopian',
certType: 'Chief Mate — Ships 5003,000 GT',
stcwRef: 'Reg. II/2 — Code A-II/2',
level: 'Management',
dept: 'Deck',
submitted: '2025-04-01',
paymentStatus: 'Verified',
status: 'Examination Passed',
examDate: '2025-05-20',
examVenue: 'EMA HQ — Addis Ababa',
seaServiceFile: 'tekle_sea_service.pdf',
trbFile: 'tekle_trb.pdf',
competencyDocs: [
{ area: 'Navigation at Management Level', uploaded: true, filename: 'tekle_nav.pdf' },
{ area: 'Cargo Handling at Management Level', uploaded: true, filename: 'tekle_cargo.pdf' },
{ area: 'Control of Ship Operations', uploaded: true, filename: 'tekle_ops.pdf' },
{ area: 'Leadership & Management', uploaded: true, filename: 'tekle_lead.pdf' },
],
trbInspectionNotes: 'TRB inspected on 2025-04-28. All competency records complete and verified by EMA examiner.',
examNotes: 'Oral examination conducted 2025-05-20. Candidate demonstrated full command of management-level navigation competencies. Result: PASS.',
officerRemarks: '',
},
];
const STATUS_OPTIONS = [
'All', 'Submitted', 'Document Review', 'TRB Inspection',
'TRB Resubmit Required', 'TRB Rejected', 'Examination Scheduled',
'Examination Passed', 'Examination Failed', 'Certificate Issued',
];
export function CoCQueuePage() {
const navigate = useNavigate();
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState('All');
const filtered = MOCK_COC_APPS.filter((a) => {
const q = search.toLowerCase();
const matchSearch = !q || a.name.toLowerCase().includes(q) || a.id.toLowerCase().includes(q) || a.seafarerId.toLowerCase().includes(q);
const matchStatus = statusFilter === 'All' || a.status === statusFilter;
return matchSearch && matchStatus;
});
const stats = {
total: MOCK_COC_APPS.length,
trbPending: MOCK_COC_APPS.filter((a) => a.status === 'TRB Inspection' || a.status === 'TRB Resubmit Required').length,
examSched: MOCK_COC_APPS.filter((a) => a.status === 'Examination Scheduled').length,
issued: MOCK_COC_APPS.filter((a) => a.status === 'Certificate Issued' || a.status === 'Examination Passed').length,
};
return (
<Stack gap="md">
<div>
<Title order={3}>CoC / CoP Application Queue</Title>
<Text fz="sm" c="dimmed">Review documents, inspect TRBs, schedule examinations and issue certificates</Text>
</div>
{/* Stats */}
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="sm">
{[
{ label: 'Total Applications', value: stats.total, color: 'blue', icon: IconShieldCheck },
{ label: 'TRB Inspection', value: stats.trbPending, color: 'yellow', icon: IconBook2 },
{ label: 'Exam Scheduled', value: stats.examSched, color: 'indigo', icon: IconClock },
{ label: 'Passed / Issued', value: stats.issued, color: 'teal', icon: IconCircleCheck },
].map(({ label, value, color, icon: Icon }) => (
<Card key={label} withBorder radius="md" p="sm">
<Group gap="xs" wrap="nowrap">
<ThemeIcon variant="light" color={color} size={38} radius="md"><Icon size={18} /></ThemeIcon>
<div><Text fz="xl" fw={700} lh={1}>{value}</Text><Text fz="xs" c="dimmed">{label}</Text></div>
</Group>
</Card>
))}
</SimpleGrid>
{/* Table */}
<Paper withBorder radius="md">
<Group p="md" justify="space-between" wrap="wrap" gap="sm">
<Text fw={600}>Application Queue</Text>
<Group gap="sm" wrap="nowrap">
<TextInput
placeholder="Search by name, App ID or Seafarer ID…"
leftSection={<IconSearch size={15} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
size="sm"
style={{ minWidth: rem(280) }}
rightSection={search ? <ActionIcon variant="subtle" size="sm" onClick={() => setSearch('')}><IconX size={13} /></ActionIcon> : null}
/>
<Select
data={STATUS_OPTIONS}
value={statusFilter}
onChange={(v) => setStatusFilter(v ?? 'All')}
size="sm"
style={{ width: rem(200) }}
/>
</Group>
</Group>
{filtered.length === 0 ? (
<Box py="xl" ta="center">
<ThemeIcon variant="light" color="gray" size={48} radius="xl" mx="auto" mb="sm"><IconShieldCheck size={22} /></ThemeIcon>
<Text fz="sm" c="dimmed">No applications found</Text>
</Box>
) : (
<Table highlightOnHover striped verticalSpacing="sm" fz="sm">
<Table.Thead bg="var(--mantine-color-default-hover)">
<Table.Tr>
{['App ID', 'Seafarer', 'Certificate', 'Level', 'Docs', 'TRB', 'Payment', 'Status', ''].map((h) => (
<Table.Th key={h} style={{ fontSize: rem(11), textTransform: 'uppercase', color: 'var(--mantine-color-dimmed)', whiteSpace: 'nowrap' }}>{h}</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{filtered.map((app) => {
const allDocs = app.competencyDocs.every((d) => d.uploaded);
return (
<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}>{app.name}</Text>
<Text fz="xs" c="dimmed">{app.seafarerId}</Text>
</Table.Td>
<Table.Td><Text fz="xs" maw={160} lh={1.3}>{app.certType}</Text></Table.Td>
<Table.Td>
<Badge size="xs" variant="light" color={app.level === 'Management' ? 'violet' : app.level === 'Operational' ? 'blue' : 'gray'}>
{app.level}
</Badge>
</Table.Td>
<Table.Td>
<Badge size="xs" variant="light" color={allDocs ? 'teal' : 'orange'}>
{app.competencyDocs.filter(d => d.uploaded).length}/{app.competencyDocs.length}
</Badge>
</Table.Td>
<Table.Td>
{app.trbFile
? <Badge size="xs" variant="light" color="teal"><IconCheck size={10} /> Submitted</Badge>
: <Badge size="xs" variant="light" color="gray">N/A</Badge>}
</Table.Td>
<Table.Td>
<Badge size="xs" variant="light" color={app.paymentStatus === 'Verified' ? 'teal' : 'orange'}>
{app.paymentStatus}
</Badge>
</Table.Td>
<Table.Td>
<Badge size="xs" variant="light" color={STATUS_COLOR[app.status]}>{app.status}</Badge>
</Table.Td>
<Table.Td>
<Button size="xs" variant="subtle" leftSection={<IconEye size={13} />} onClick={() => navigate(`/coc-queue/${app.id}`)}>
Review
</Button>
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
)}
{filtered.length > 0 && (
<Group px="md" py="sm" justify="space-between">
<Text fz="xs" c="dimmed">Showing {filtered.length} of {MOCK_COC_APPS.length} applications</Text>
</Group>
)}
</Paper>
</Stack>
);
}

View File

@@ -0,0 +1,621 @@
import { useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import {
ActionIcon,
Alert,
Badge,
Box,
Button,
Card,
Divider,
Group,
Modal,
Paper,
Select,
SimpleGrid,
Stack,
Tabs,
Text,
Textarea,
TextInput,
ThemeIcon,
Title,
rem,
} from '@mantine/core';
import {
IconAlertTriangle,
IconArrowLeft,
IconBook2,
IconCalendar,
IconCertificate,
IconCheck,
IconCircleCheck,
IconCreditCard,
IconExternalLink,
IconEye,
IconFileDescription,
IconInfoCircle,
IconShieldCheck,
IconUser,
IconX,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { MOCK_COC_APPS, STATUS_COLOR } from './CoCQueuePage';
import type { CoCApp, CoCAppStatus } from './CoCQueuePage';
// ---------------------------------------------------------------------------
// Demo doc viewer (same pattern as ApplicationReviewPage)
// ---------------------------------------------------------------------------
const DEMO_PDF = 'data:application/pdf;base64,JVBERi0xLjQKJeLjz9MKMSAwIG9iago8PC9UeXBlL0NhdGFsb2cvUGFnZXMgMiAwIFI+PgplbmRvYmoKMiAwIG9iago8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PgplbmRvYmoKMyAwIG9iago8PC9UeXBlL1BhZ2UvUGFyZW50IDIgMCBSL01lZGlhQm94WzAgMCA2MTIgNzkyXT4+CmVuZG9iagp4cmVmCjAgNAowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMDkgMDAwMDAgbiAKMDAwMDAwMDA1OCAwMDAwMCBuIAowMDAwMDAwMTE1IDAwMDAwIG4gCnRyYWlsZXIKPDwvU2l6ZSA0L1Jvb3QgMSAwIFI+PgpzdGFydHhyZWYKMjIwCiUlRU9G';
function getDocUrl(fileName: string) {
if (!fileName) return '';
return fileName.endsWith('.pdf') ? DEMO_PDF : `https://placehold.co/600x400/e9ecef/6c757d?text=${encodeURIComponent(fileName)}`;
}
function DocViewer({ fileName, label }: { fileName: string; label: string }) {
const [open, setOpen] = useState(false);
const isPdf = fileName.endsWith('.pdf');
const url = getDocUrl(fileName);
if (!fileName) return <Badge size="xs" color="red" variant="light">Not uploaded</Badge>;
return (
<>
<Card withBorder radius="md" p="sm">
<Group gap="sm" wrap="nowrap" mb="sm">
<ThemeIcon size="md" variant="light" color={isPdf ? 'red' : 'blue'} radius="md">
<IconFileDescription size={16} stroke={1.5} />
</ThemeIcon>
<div style={{ flex: 1, minWidth: 0 }}>
<Text fz="sm" fw={600}>{label}</Text>
<Text fz="xs" c="dimmed" truncate>{fileName}</Text>
</div>
<Badge size="xs" variant="light" color={isPdf ? 'red' : 'blue'}>{isPdf ? 'PDF' : 'IMG'}</Badge>
</Group>
<Box style={{ width: '100%', height: rem(180), borderRadius: rem(6), overflow: 'hidden', border: '1px solid var(--mantine-color-default-border)', background: 'var(--mantine-color-gray-0)' }}>
{isPdf
? <iframe src={url} style={{ width: '100%', height: '100%', border: 'none' }} title={fileName} />
: <img src={url} alt={fileName} style={{ width: '100%', height: '100%', objectFit: 'contain' }} />}
</Box>
<Button size="xs" variant="subtle" fullWidth mt="xs" leftSection={<IconEye size={13} />} rightSection={<IconExternalLink size={13} />} onClick={() => setOpen(true)}>
View Full Document
</Button>
</Card>
<Modal opened={open} onClose={() => setOpen(false)} title={<Text fw={700}>{label} {fileName}</Text>} size="90vw" styles={{ body: { padding: 0, height: '80vh' } }}>
{isPdf
? <iframe src={url} style={{ width: '100%', height: '100%', border: 'none' }} title={fileName} />
: <img src={url} alt={fileName} style={{ width: '100%', height: '100%', objectFit: 'contain' }} />}
</Modal>
</>
);
}
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div>
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} lh={1.2}>{label}</Text>
<Text fz="sm" mt={2}>{value || '—'}</Text>
</div>
);
}
function SectionLabel({ children }: { children: string }) {
return <Text fw={600} fz="xs" tt="uppercase" c="gray.6" mb="sm">{children}</Text>;
}
// ---------------------------------------------------------------------------
// Workflow progress bar
// ---------------------------------------------------------------------------
const STEP_ORDER: CoCAppStatus[] = [
'Submitted', 'Document Review', 'TRB Inspection',
'Examination Scheduled', 'Examination Passed', 'Certificate Issued',
];
function WorkflowBar({ status }: { status: CoCAppStatus }) {
const activeIdx = STEP_ORDER.indexOf(status);
const isBranch = ['TRB Rejected', 'TRB Resubmit Required', 'Examination Failed'].includes(status);
const labels = ['Submitted', 'Doc Review', 'TRB Inspect', 'Exam Scheduled', 'Exam Passed', 'Issued'];
return (
<Paper withBorder radius="md" p="md" mb="md">
<Text fz="xs" c="dimmed" fw={700} tt="uppercase" mb="xs">Application Progress</Text>
<Group gap={0} align="flex-start" wrap="nowrap">
{STEP_ORDER.map((s, i) => {
const done = isBranch ? false : activeIdx > i;
const current = !isBranch && activeIdx === i;
return (
<Group key={s} gap={0} align="center" style={{ flex: i < STEP_ORDER.length - 1 ? 1 : 'none' }}>
<Stack gap={2} align="center" style={{ minWidth: rem(52) }}>
<Box style={{
width: rem(30), height: rem(30), borderRadius: '50%',
background: done ? 'var(--mantine-color-teal-6)' : current ? 'var(--mantine-color-blue-6)' : 'var(--mantine-color-gray-2)',
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}>
{done
? <IconCheck size={14} color="white" stroke={2.5} />
: <Text fz="xs" fw={700} c={current ? 'white' : 'gray.5'}>{i + 1}</Text>}
</Box>
<Text fz={9} fw={current || done ? 700 : 400} c={done ? 'teal.7' : current ? 'blue.7' : 'dimmed'} ta="center" lh={1.2}>{labels[i]}</Text>
</Stack>
{i < STEP_ORDER.length - 1 && (
<Box style={{ flex: 1, height: rem(2), background: done ? 'var(--mantine-color-teal-4)' : 'var(--mantine-color-gray-2)', marginBottom: rem(20) }} />
)}
</Group>
);
})}
</Group>
{isBranch && (
<Alert variant="light" color="red" icon={<IconAlertTriangle size={13} />} p="xs" mt="xs">
<Text fz="xs" fw={600}>
{status === 'TRB Resubmit Required' && 'TRB resubmission required — applicant notified.'}
{status === 'TRB Rejected' && 'TRB rejected — applicant must submit a new application.'}
{status === 'Examination Failed' && 'Examination failed — applicant must submit a new application.'}
</Text>
</Alert>
)}
</Paper>
);
}
// ---------------------------------------------------------------------------
// Action modal
// ---------------------------------------------------------------------------
const EXAM_VENUES = [
{ value: 'addis', label: 'EMA HQ — Addis Ababa' },
{ value: 'regional', label: 'EMA Regional Office — Djibouti Liaison' },
];
type ActionKey = 'advance_trb' | 'request_docs' | 'approve_trb' | 'resubmit_trb' | 'reject_trb' | 'exam_pass' | 'exam_fail' | 'issue_cert';
const ACTION_TO_STATUS: Record<ActionKey, CoCAppStatus> = {
advance_trb: 'TRB Inspection',
request_docs: 'Document Review',
approve_trb: 'Examination Scheduled',
resubmit_trb: 'TRB Resubmit Required',
reject_trb: 'TRB Rejected',
exam_pass: 'Examination Passed',
exam_fail: 'Examination Failed',
issue_cert: 'Certificate Issued',
};
function getActions(status: CoCAppStatus): { value: ActionKey; label: string }[] {
switch (status) {
case 'Submitted':
case 'Document Review':
return [
{ value: 'advance_trb', label: 'Documents OK — Move to TRB Inspection' },
{ value: 'request_docs', label: 'Request Additional Documents' },
];
case 'TRB Inspection':
case 'TRB Resubmit Required':
return [
{ value: 'approve_trb', label: 'TRB Approved — Schedule Examination' },
{ value: 'resubmit_trb', label: 'Request TRB Resubmission' },
{ value: 'reject_trb', label: 'Reject TRB (Close Application)' },
];
case 'Examination Scheduled':
return [
{ value: 'exam_pass', label: 'Record Result — Passed' },
{ value: 'exam_fail', label: 'Record Result — Failed' },
];
case 'Examination Passed':
return [{ value: 'issue_cert', label: 'Issue Certificate' }];
default:
return [];
}
}
function ActionModal({
app,
opened,
onClose,
onAction,
}: {
app: CoCApp;
opened: boolean;
onClose: () => void;
onAction: (newStatus: CoCAppStatus, notes: string, examDate?: string, examVenue?: string) => void;
}) {
const [action, setAction] = useState<string | null>(null);
const [notes, setNotes] = useState('');
const [examDate, setExamDate] = useState('');
const [venue, setVenue] = useState<string | null>(null);
const actionOptions = getActions(app.status);
const needsExam = action === 'approve_trb';
const isDestructive = action === 'reject_trb' || action === 'exam_fail';
const handleConfirm = () => {
if (!action) return;
if (needsExam && (!examDate || !venue)) {
notify.error('Please set an examination date and venue.'); return;
}
const newStatus = ACTION_TO_STATUS[action as ActionKey];
const venueLabel = EXAM_VENUES.find(v => v.value === venue)?.label ?? venue ?? undefined;
onAction(newStatus, notes, examDate || undefined, venueLabel);
setAction(null); setNotes(''); setExamDate(''); setVenue(null);
onClose();
};
return (
<Modal
opened={opened}
onClose={onClose}
title={<Group gap="xs"><IconShieldCheck size={17} /><Text fw={700}>Officer Action</Text></Group>}
size="md"
radius="lg"
>
<Stack gap="md">
{actionOptions.length === 0 ? (
<Alert variant="light" color="teal" icon={<IconCircleCheck size={15} />}>
<Text fz="sm" fw={600}>No further actions available for this status.</Text>
</Alert>
) : (
<>
<Select
label="Action"
placeholder="Select an action…"
data={actionOptions}
value={action}
onChange={setAction}
size="sm"
/>
{needsExam && (
<SimpleGrid cols={2} spacing="sm">
<TextInput label="Examination Date" type="date" value={examDate} onChange={(e) => setExamDate(e.currentTarget.value)} leftSection={<IconCalendar size={14} />} size="sm" required />
<Select label="Venue" placeholder="Select venue" data={EXAM_VENUES} value={venue} onChange={setVenue} size="sm" required />
</SimpleGrid>
)}
<Textarea
label="Notes / Remarks (sent to applicant)"
placeholder="TRB inspection findings, exam result details, rejection reason…"
value={notes}
onChange={(e) => setNotes(e.currentTarget.value)}
rows={3}
size="sm"
/>
{isDestructive && (
<Alert variant="light" color="red" icon={<IconAlertTriangle size={15} />} p="sm">
<Text fz="xs" fw={600}>
{action === 'reject_trb' && 'This will permanently close the application. The applicant must submit a new application.'}
{action === 'exam_fail' && 'This will record an examination failure. The applicant must apply again to re-attempt.'}
</Text>
</Alert>
)}
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={onClose}>Cancel</Button>
<Button
leftSection={<IconCheck size={14} />}
disabled={!action}
color={isDestructive ? 'red' : action === 'issue_cert' || action === 'approve_trb' || action === 'exam_pass' ? 'teal' : 'blue'}
onClick={handleConfirm}
>
Confirm
</Button>
</Group>
</>
)}
</Stack>
</Modal>
);
}
// ---------------------------------------------------------------------------
// Main review page
// ---------------------------------------------------------------------------
export function CoCReviewPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const original = MOCK_COC_APPS.find((a) => a.id === id);
const [app, setApp] = useState<CoCApp | null>(original ?? null);
const [actionModalOpen, setActionModalOpen] = useState(false);
if (!app) {
return (
<Stack gap="md">
<Button variant="subtle" leftSection={<IconArrowLeft size={15} />} w="fit-content" onClick={() => navigate('/coc-queue')}>Back to Queue</Button>
<Alert variant="light" color="red" icon={<IconAlertTriangle size={17} />}>Application not found.</Alert>
</Stack>
);
}
const handleAction = (newStatus: CoCAppStatus, notes: string, examDate?: string, examVenue?: string) => {
const trbStatuses: CoCAppStatus[] = ['TRB Inspection', 'TRB Resubmit Required', 'TRB Rejected', 'Examination Scheduled'];
const examStatuses: CoCAppStatus[] = ['Examination Passed', 'Examination Failed'];
setApp((prev) => {
if (!prev) return prev;
return {
...prev,
status: newStatus,
examDate: examDate ?? prev.examDate,
examVenue: examVenue ?? prev.examVenue,
trbInspectionNotes: trbStatuses.includes(newStatus) ? notes || prev.trbInspectionNotes : prev.trbInspectionNotes,
examNotes: examStatuses.includes(newStatus) ? notes || prev.examNotes : prev.examNotes,
officerRemarks: notes || prev.officerRemarks,
};
});
const labels: Record<CoCAppStatus, string> = {
'Submitted': 'moved to Submitted',
'Document Review': 'moved to Document Review',
'TRB Inspection': 'moved to TRB Inspection',
'TRB Resubmit Required': 'TRB resubmission requested',
'TRB Rejected': 'TRB rejected — application closed',
'Examination Scheduled': `examination scheduled for ${examDate}`,
'Examination Passed': 'examination result recorded — Passed',
'Examination Failed': 'examination result recorded — Failed',
'Certificate Issued': 'certificate issued',
};
notify.success(`Application ${labels[newStatus] ?? newStatus}.`);
};
const allDocs = app.competencyDocs.every((d) => d.uploaded);
const actionOptions = getActions(app.status);
const isTerminal = ['TRB Rejected', 'Examination Failed', 'Certificate Issued'].includes(app.status);
return (
<Stack gap="md">
{/* Header */}
<Group justify="space-between">
<Group gap="sm">
<ActionIcon variant="subtle" size="lg" onClick={() => navigate('/coc-queue')}>
<IconArrowLeft size={18} />
</ActionIcon>
<div>
<Title order={3}>CoC / CoP Application Review</Title>
<Group gap={6} mt={2}>
<Text fz="sm" c="dimmed">{app.id}</Text>
<Text fz="sm" c="dimmed">·</Text>
<Text fz="sm" c="dimmed">Submitted {app.submitted}</Text>
</Group>
</div>
</Group>
<Badge size="lg" variant="light" color={STATUS_COLOR[app.status]}>{app.status}</Badge>
</Group>
{/* Action bar */}
{!isTerminal && actionOptions.length > 0 && (
<Paper withBorder radius="lg" p="md" bg="gray.0">
<Group gap="sm" justify="flex-end">
<Text fz="sm" c="dimmed" style={{ flex: 1 }}>Review all tabs, then take an action:</Text>
<Button size="sm" leftSection={<IconShieldCheck size={15} />} onClick={() => setActionModalOpen(true)}>
Take Action
</Button>
</Group>
</Paper>
)}
{isTerminal && (
<Alert
variant="light"
color={app.status === 'Certificate Issued' ? 'teal' : 'red'}
icon={app.status === 'Certificate Issued' ? <IconCircleCheck size={17} /> : <IconAlertTriangle size={17} />}
>
<Text fz="sm" fw={600}>
{app.status === 'Certificate Issued' && 'Certificate has been issued and dispatched to the seafarer.'}
{app.status === 'TRB Rejected' && 'TRB has been rejected. This application is closed. Applicant must re-apply.'}
{app.status === 'Examination Failed' && 'Examination failed. This application is closed. Applicant must re-apply.'}
</Text>
</Alert>
)}
<WorkflowBar status={app.status} />
<Tabs defaultValue="profile" variant="outline" radius="md">
<Tabs.List mb="md">
<Tabs.Tab value="profile" leftSection={<IconUser size={16} />}>Applicant</Tabs.Tab>
<Tabs.Tab value="competency" leftSection={<IconFileDescription size={16} />}>Competency Docs</Tabs.Tab>
<Tabs.Tab value="trb" leftSection={<IconBook2 size={16} />}>TRB Inspection</Tabs.Tab>
<Tabs.Tab value="exam" leftSection={<IconCertificate size={16} />}>Examination</Tabs.Tab>
<Tabs.Tab value="payment" leftSection={<IconCreditCard size={16} />}>Payment</Tabs.Tab>
<Tabs.Tab value="issuance" leftSection={<IconShieldCheck size={16} />}>Issuance</Tabs.Tab>
</Tabs.List>
{/* ── Applicant ─────────────────────────────────────────── */}
<Tabs.Panel value="profile">
<Paper withBorder radius="lg" p="xl">
<Group gap="sm" mb="lg">
<ThemeIcon size="xl" variant="light" radius="lg"><IconUser size={22} stroke={1.5} /></ThemeIcon>
<div>
<Text fw={700} fz="lg">{app.name}</Text>
<Text fz="sm" c="dimmed">{app.seafarerId}</Text>
</div>
</Group>
<Divider mb="md" />
<SectionLabel>Personal Information</SectionLabel>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md" mb="lg">
<InfoRow label="Email" value={app.email} />
<InfoRow label="Mobile" value={app.mobile} />
<InfoRow label="Nationality" value={app.nationality} />
</SimpleGrid>
<Divider mb="md" />
<SectionLabel>Certificate Applied For</SectionLabel>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<InfoRow label="Certificate" value={app.certType} />
<InfoRow label="STCW Reference" value={app.stcwRef} />
<InfoRow label="Level" value={app.level} />
<InfoRow label="Department" value={app.dept} />
<InfoRow label="Submitted" value={app.submitted} />
</SimpleGrid>
</Paper>
</Tabs.Panel>
{/* ── Competency Documents ──────────────────────────────── */}
<Tabs.Panel value="competency">
<Stack gap="md">
<Alert variant="light" color={allDocs ? 'teal' : 'orange'} icon={allDocs ? <IconCircleCheck size={17} /> : <IconInfoCircle size={17} />}>
{allDocs
? `All ${app.competencyDocs.length} competency certificates uploaded.`
: `${app.competencyDocs.filter(d => !d.uploaded).length} competency certificate(s) missing.`}
</Alert>
{/* Sea service */}
<Paper withBorder radius="lg" p="lg">
<SectionLabel>Sea Service Record</SectionLabel>
<Box maw={360}>
<DocViewer fileName={app.seaServiceFile} label="Sea Service Record / Discharge Book" />
</Box>
</Paper>
{/* Competency certs */}
<Paper withBorder radius="lg" p="lg">
<SectionLabel>Competency Certificates ({app.competencyDocs.length} areas)</SectionLabel>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{app.competencyDocs.map((doc) => (
<div key={doc.area}>
<Group gap="xs" mb="xs">
{doc.uploaded
? <ThemeIcon size={18} radius="xl" color="teal" variant="filled"><IconCheck size={11} /></ThemeIcon>
: <ThemeIcon size={18} radius="xl" color="red" variant="light"><IconX size={11} /></ThemeIcon>}
<Text fz="xs" fw={700}>{doc.area}</Text>
</Group>
<DocViewer fileName={doc.filename} label={doc.area} />
</div>
))}
</SimpleGrid>
</Paper>
</Stack>
</Tabs.Panel>
{/* ── TRB Inspection ────────────────────────────────────── */}
<Tabs.Panel value="trb">
<Stack gap="md">
<Alert variant="light" color="yellow" icon={<IconInfoCircle size={17} />}>
The Training Record Book (TRB) must be physically inspected at the EMA office.
The applicant brings the original; record your inspection findings below after taking action.
</Alert>
<Paper withBorder radius="lg" p="lg">
<SectionLabel>Submitted TRB</SectionLabel>
{app.trbFile
? <Box maw={360}><DocViewer fileName={app.trbFile} label="Training Record Book (TRB)" /></Box>
: <Badge color="gray" variant="light">Not submitted (entry-level certificate TRB not required)</Badge>}
</Paper>
{app.trbInspectionNotes && (
<Paper withBorder radius="lg" p="lg">
<SectionLabel>TRB Inspection Notes</SectionLabel>
<Text fz="sm">{app.trbInspectionNotes}</Text>
</Paper>
)}
{!app.trbInspectionNotes && app.trbFile && (
<Alert variant="light" color="blue" icon={<IconBook2 size={17} />}>
TRB inspection has not yet been recorded. Use "Take Action" above to approve, request resubmission, or reject the TRB.
</Alert>
)}
</Stack>
</Tabs.Panel>
{/* ── Examination ───────────────────────────────────────── */}
<Tabs.Panel value="exam">
<Stack gap="md">
{app.examDate ? (
<Paper withBorder radius="lg" p="lg">
<SectionLabel>Scheduled Examination</SectionLabel>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md" mb="md">
<InfoRow label="Examination Date" value={app.examDate} />
<InfoRow label="Venue" value={app.examVenue ?? ''} />
</SimpleGrid>
{app.examNotes && (
<>
<Divider mb="md" />
<SectionLabel>Examination Result Notes</SectionLabel>
<Text fz="sm">{app.examNotes}</Text>
</>
)}
{['Examination Scheduled'].includes(app.status) && (
<Alert variant="light" color="indigo" icon={<IconCalendar size={17} />} mt="md">
Examination is scheduled. After the examination, use "Take Action" to record the result (Pass or Fail).
</Alert>
)}
</Paper>
) : (
<Alert variant="light" color="gray" icon={<IconInfoCircle size={17} />}>
No examination scheduled yet. Examination is assigned by an EMA officer after TRB approval.
</Alert>
)}
</Stack>
</Tabs.Panel>
{/* ── Payment ───────────────────────────────────────────── */}
<Tabs.Panel value="payment">
<Paper withBorder radius="lg" p="xl">
<Group gap="sm" mb="lg">
<ThemeIcon size="xl" variant="light" color="green" radius="lg"><IconCreditCard size={22} stroke={1.5} /></ThemeIcon>
<div>
<Text fw={700} fz="lg">Payment Status</Text>
<Badge size="sm" variant="light" color={app.paymentStatus === 'Verified' ? 'teal' : 'orange'} mt={2}>
{app.paymentStatus}
</Badge>
</div>
</Group>
<Paper withBorder radius="md" p="md" bg="gray.0" mb="lg">
<Text fw={700} fz="sm" mb="md">CoC / CoP Application Fee</Text>
{[
{ label: 'Application Processing Fee', amount: 800 },
{ label: 'Document Verification Fee', amount: 300 },
{ label: 'Examination Fee', amount: 500 },
{ label: 'Certificate Issuance Fee', amount: 400 },
].map(({ label, amount }) => (
<Group key={label} justify="space-between" mb={4}>
<Text fz="sm">{label}</Text>
<Text fz="sm" fw={500}>ETB {amount.toFixed(2)}</Text>
</Group>
))}
<Divider mt="sm" mb="xs" />
<Group justify="space-between">
<Text fz="sm" fw={800}>Total</Text>
<Text fz="md" fw={800} c="blue">ETB 2,000.00</Text>
</Group>
</Paper>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<InfoRow label="Payment Method" value="CBE Bank Transfer" />
<InfoRow label="Transaction Reference" value="CBE-TXN-20250315-042" />
<InfoRow label="Payment Date" value={app.submitted} />
</SimpleGrid>
</Paper>
</Tabs.Panel>
{/* ── Issuance ──────────────────────────────────────────── */}
<Tabs.Panel value="issuance">
<Stack gap="md">
<Alert
variant="light"
color={app.status === 'Certificate Issued' ? 'teal' : 'yellow'}
icon={app.status === 'Certificate Issued' ? <IconCircleCheck size={17} /> : <IconInfoCircle size={17} />}
>
{app.status === 'Certificate Issued'
? 'Certificate has been issued. The seafarer has been notified.'
: 'Certificate will be issued after the application is fully approved and examination passed.'}
</Alert>
<Paper withBorder radius="lg" p="lg">
<SectionLabel>Certificate to be Issued</SectionLabel>
<Card withBorder radius="lg" p="md" style={{ borderColor: app.status === 'Certificate Issued' ? 'var(--mantine-color-teal-3)' : 'var(--mantine-color-gray-2)' }}>
<Group gap="sm" mb="sm" wrap="nowrap">
<ThemeIcon size="xl" variant="light" color={app.status === 'Certificate Issued' ? 'teal' : 'gray'} radius="lg">
<IconShieldCheck size={22} stroke={1.5} />
</ThemeIcon>
<div>
<Text fw={700} fz="sm">{app.certType}</Text>
<Text fz="xs" c="dimmed">{app.stcwRef}</Text>
<Badge size="xs" variant="light" color={app.status === 'Certificate Issued' ? 'teal' : 'yellow'} mt={4}>
{app.status === 'Certificate Issued' ? 'Issued' : 'Pending'}
</Badge>
</div>
</Group>
<Divider mb="sm" />
<SimpleGrid cols={2} spacing="xs">
<InfoRow label="Validity" value="5 years" />
<InfoRow label="Revalidation" value="Before expiry — STCW Reg I/11" />
</SimpleGrid>
</Card>
</Paper>
</Stack>
</Tabs.Panel>
</Tabs>
<ActionModal
app={app}
opened={actionModalOpen}
onClose={() => setActionModalOpen(false)}
onAction={handleAction}
/>
</Stack>
);
}