mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-07 21:15:42 +00:00
ui componenet based on the requirements
This commit is contained in:
@@ -0,0 +1,377 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Drawer,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconBook2,
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconClock,
|
||||
IconEye,
|
||||
IconFileDescription,
|
||||
IconHeart,
|
||||
IconSearch,
|
||||
IconShieldCheck,
|
||||
IconUsers,
|
||||
IconX,
|
||||
IconAlertCircle,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types & mock data
|
||||
// ---------------------------------------------------------------------------
|
||||
interface SeamanBookApp {
|
||||
id: string;
|
||||
seafarerId: string;
|
||||
name: string;
|
||||
email: string;
|
||||
mobile: string;
|
||||
nationality: string;
|
||||
submitted: string;
|
||||
status: 'Pending' | 'Under Review' | 'Awaiting Docs' | 'Approved' | 'Rejected' | 'Correction Required';
|
||||
medicalStatus: 'Valid' | 'Expiring' | 'Expired' | 'Missing';
|
||||
bstComplete: boolean;
|
||||
bstCount: number;
|
||||
docsComplete: boolean;
|
||||
remarks: string;
|
||||
}
|
||||
|
||||
const MOCK_APPS: SeamanBookApp[] = [
|
||||
{ id: 'SB-APP-2024-001', seafarerId: 'SF-2024-0001', name: 'Abebe Girma', email: 'abebe.g@email.com', mobile: '+251 911 234 567', nationality: 'Ethiopian', submitted: '2024-05-10', status: 'Under Review', medicalStatus: 'Expiring', bstComplete: true, bstCount: 5, docsComplete: true, remarks: 'All documents submitted. Under initial review.' },
|
||||
{ id: 'SB-APP-2024-002', seafarerId: 'SF-2024-0002', name: 'Sara Tadesse', email: 'sara.t@email.com', mobile: '+251 922 345 678', nationality: 'Ethiopian', submitted: '2024-05-12', status: 'Awaiting Docs', medicalStatus: 'Valid', bstComplete: false, bstCount: 3, docsComplete: false, remarks: 'Missing EFA and PSSR certificates.' },
|
||||
{ id: 'SB-APP-2024-003', seafarerId: 'SF-2024-0003', name: 'Dawit Bekele', email: 'dawit.b@email.com', mobile: '+251 933 456 789', nationality: 'Ethiopian', submitted: '2024-05-14', status: 'Under Review', medicalStatus: 'Valid', bstComplete: true, bstCount: 5, docsComplete: true, remarks: 'Pending document authenticity check.' },
|
||||
{ id: 'SB-APP-2024-004', seafarerId: 'SF-2024-0004', name: 'Hana Mulugeta', email: 'hana.m@email.com', mobile: '+251 944 567 890', nationality: 'Ethiopian', submitted: '2024-05-15', status: 'Correction Required', medicalStatus: 'Valid', bstComplete: true, bstCount: 5, docsComplete: false, remarks: 'National ID scan is unclear. Please resubmit.' },
|
||||
{ id: 'SB-APP-2024-005', seafarerId: 'SF-2024-0005', name: 'Yonas Tesfaye', email: 'yonas.t@email.com', mobile: '+251 955 678 901', nationality: 'Ethiopian', submitted: '2024-05-16', status: 'Pending', medicalStatus: 'Valid', bstComplete: true, bstCount: 5, docsComplete: true, remarks: '' },
|
||||
{ id: 'SB-APP-2024-006', seafarerId: 'SF-2024-0006', name: 'Meron Alemu', email: 'meron.a@email.com', mobile: '+251 966 789 012', nationality: 'Ethiopian', submitted: '2024-05-18', status: 'Approved', medicalStatus: 'Valid', bstComplete: true, bstCount: 5, docsComplete: true, remarks: 'All verified. Ready to print.' },
|
||||
];
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
Pending: 'gray', 'Under Review': 'yellow', 'Awaiting Docs': 'orange',
|
||||
Approved: 'teal', Rejected: 'red', 'Correction Required': 'red',
|
||||
};
|
||||
|
||||
const MEDICAL_COLOR: Record<string, string> = { Valid: 'teal', Expiring: 'orange', Expired: 'red', Missing: 'red' };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Detail drawer
|
||||
// ---------------------------------------------------------------------------
|
||||
function AppDrawer({
|
||||
app,
|
||||
opened,
|
||||
onClose,
|
||||
onAction,
|
||||
onFullReview,
|
||||
}: {
|
||||
app: SeamanBookApp | null;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onAction: (id: string, action: 'approve' | 'reject' | 'correction', remarks: string) => void;
|
||||
onFullReview: (id: string) => void;
|
||||
}) {
|
||||
const [remarks, setRemarks] = useState('');
|
||||
const [confirmModal, setConfirmModal] = useState<'approve' | 'reject' | 'correction' | null>(null);
|
||||
|
||||
if (!app) return null;
|
||||
|
||||
const submit = (action: 'approve' | 'reject' | 'correction') => {
|
||||
onAction(app.id, action, remarks);
|
||||
setRemarks('');
|
||||
setConfirmModal(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Drawer
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={`Application ${app.id}`}
|
||||
position="right"
|
||||
size="lg"
|
||||
padding="lg"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<IconEye size={15} />}
|
||||
fullWidth
|
||||
onClick={() => { onClose(); onFullReview(app.id); }}
|
||||
>
|
||||
Open Full Review Page
|
||||
</Button>
|
||||
{/* Seafarer info */}
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Seafarer Information</Text>
|
||||
<SimpleGrid cols={2} spacing="xs">
|
||||
{[
|
||||
['Seafarer ID', app.seafarerId],
|
||||
['Full Name', app.name],
|
||||
['Email', app.email],
|
||||
['Mobile', app.mobile],
|
||||
['Nationality', app.nationality],
|
||||
['Submitted', app.submitted],
|
||||
].map(([label, value]) => (
|
||||
<Box key={label}>
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>{label}</Text>
|
||||
<Text fz="sm">{value}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
{/* Eligibility checklist */}
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Eligibility Verification</Text>
|
||||
<Stack gap="xs">
|
||||
{[
|
||||
{ label: 'Seafarer Profile Complete', ok: true, icon: IconUsers },
|
||||
{ label: 'National ID / Fayda Uploaded', ok: app.docsComplete, icon: IconFileDescription },
|
||||
{ label: `Medical Certificate (${app.medicalStatus})`, ok: app.medicalStatus === 'Valid', icon: IconHeart },
|
||||
{ label: `Basic Safety Training (${app.bstCount}/5)`, ok: app.bstComplete, icon: IconShieldCheck },
|
||||
{ label: 'All Documents Uploaded', ok: app.docsComplete, icon: IconFileDescription },
|
||||
].map(({ label, ok, icon: Icon }) => (
|
||||
<Group key={label} gap="xs">
|
||||
<ThemeIcon size={22} radius="xl" variant={ok ? 'filled' : 'light'} color={ok ? 'teal' : 'red'}>
|
||||
{ok ? <IconCheck size={13} /> : <IconX size={13} />}
|
||||
</ThemeIcon>
|
||||
<Text fz="sm" c={ok ? undefined : 'dimmed'}>{label}</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Current status */}
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Text fw={700} fz="sm">Current Status</Text>
|
||||
<Badge color={STATUS_COLOR[app.status] ?? 'gray'} variant="light">{app.status}</Badge>
|
||||
</Group>
|
||||
{app.remarks && <Text fz="sm" c="dimmed">{app.remarks}</Text>}
|
||||
</Paper>
|
||||
|
||||
{/* Officer remarks */}
|
||||
<Textarea
|
||||
label="Officer Remarks"
|
||||
placeholder="Add notes or reason for decision…"
|
||||
minRows={3}
|
||||
value={remarks}
|
||||
onChange={(e) => setRemarks(e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Action buttons */}
|
||||
<Group grow>
|
||||
<Button
|
||||
color="teal"
|
||||
leftSection={<IconCircleCheck size={15} />}
|
||||
onClick={() => setConfirmModal('approve')}
|
||||
disabled={!app.docsComplete || !app.bstComplete || app.medicalStatus === 'Expired' || app.medicalStatus === 'Missing'}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
color="orange"
|
||||
variant="light"
|
||||
leftSection={<IconAlertCircle size={15} />}
|
||||
onClick={() => setConfirmModal('correction')}
|
||||
>
|
||||
Request Correction
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
variant="light"
|
||||
leftSection={<IconX size={15} />}
|
||||
onClick={() => setConfirmModal('reject')}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Drawer>
|
||||
|
||||
<Modal
|
||||
opened={!!confirmModal}
|
||||
onClose={() => setConfirmModal(null)}
|
||||
title={`Confirm ${confirmModal === 'approve' ? 'Approval' : confirmModal === 'reject' ? 'Rejection' : 'Correction Request'}`}
|
||||
centered
|
||||
size="sm"
|
||||
>
|
||||
<Text fz="sm" mb="md">
|
||||
{confirmModal === 'approve'
|
||||
? 'Are you sure you want to approve this Seaman Book application? The seafarer will be notified.'
|
||||
: confirmModal === 'reject'
|
||||
? 'Are you sure you want to reject this application? Please ensure you have added remarks explaining the reason.'
|
||||
: 'A correction request will be sent to the seafarer with your remarks. Are you sure?'}
|
||||
</Text>
|
||||
{!remarks && confirmModal !== 'approve' && (
|
||||
<Text fz="xs" c="red" mb="sm">Please add remarks before proceeding.</Text>
|
||||
)}
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={() => setConfirmModal(null)}>Cancel</Button>
|
||||
<Button
|
||||
color={confirmModal === 'approve' ? 'teal' : confirmModal === 'reject' ? 'red' : 'orange'}
|
||||
onClick={() => submit(confirmModal!)}
|
||||
disabled={!remarks && confirmModal !== 'approve'}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main page
|
||||
// ---------------------------------------------------------------------------
|
||||
export function SeamanBookQueuePage() {
|
||||
const navigate = useNavigate();
|
||||
const [apps, setApps] = useState<SeamanBookApp[]>(MOCK_APPS);
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
||||
|
||||
|
||||
const stats = {
|
||||
total: apps.length,
|
||||
pending: apps.filter((a) => a.status === 'Pending' || a.status === 'Under Review').length,
|
||||
awaitingDocs: apps.filter((a) => a.status === 'Awaiting Docs').length,
|
||||
approved: apps.filter((a) => a.status === 'Approved').length,
|
||||
};
|
||||
|
||||
const filtered = 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 || a.status === statusFilter;
|
||||
return matchSearch && matchStatus;
|
||||
});
|
||||
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<Title order={3}>Seaman Book Applications</Title>
|
||||
<Text fz="sm" c="dimmed">Review and process seafarer Seaman Book applications</Text>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="sm">
|
||||
{[
|
||||
{ label: 'Total', value: stats.total, color: 'blue', icon: IconBook2 },
|
||||
{ label: 'Under Review', value: stats.pending, color: 'yellow', icon: IconClock },
|
||||
{ label: 'Awaiting Docs', value: stats.awaitingDocs,color: 'orange', icon: IconFileDescription },
|
||||
{ label: 'Approved', value: stats.approved, 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
|
||||
placeholder="All Status"
|
||||
data={['Pending', 'Under Review', 'Awaiting Docs', 'Correction Required', 'Approved', 'Rejected']}
|
||||
value={statusFilter}
|
||||
onChange={setStatusFilter}
|
||||
clearable
|
||||
size="sm"
|
||||
style={{ width: rem(180) }}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<Box py="xl" ta="center">
|
||||
<ThemeIcon variant="light" color="gray" size={48} radius="xl" mx="auto" mb="sm"><IconBook2 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', 'Submitted', 'Medical', 'BST', 'Documents', '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) => (
|
||||
<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">{app.submitted}</Text></Table.Td>
|
||||
<Table.Td><Badge color={MEDICAL_COLOR[app.medicalStatus]} variant="light" size="xs">{app.medicalStatus}</Badge></Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={app.bstComplete ? 'teal' : 'red'} variant="light" size="xs">
|
||||
{app.bstCount}/5
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={app.docsComplete ? 'teal' : 'orange'} variant="light" size="xs">
|
||||
{app.docsComplete ? 'Complete' : 'Incomplete'}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td><Badge color={STATUS_COLOR[app.status] ?? 'gray'} variant="light" size="xs">{app.status}</Badge></Table.Td>
|
||||
<Table.Td>
|
||||
<Button size="xs" variant="subtle" leftSection={<IconEye size={13} />} onClick={() => navigate(`/applications/${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 {apps.length} applications</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user