mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 13:02:50 +00:00
UI
This commit is contained in:
@@ -39,6 +39,7 @@ import {
|
||||
useLocalized,
|
||||
useApproveDocumentsMutation,
|
||||
useAssignApplicationMutation,
|
||||
useClaimApplicationMutation,
|
||||
useCompleteReviewMutation,
|
||||
useConfirmPaymentMutation,
|
||||
useScheduleIssuanceMutation,
|
||||
@@ -198,6 +199,7 @@ export function LicenseReviewPage() {
|
||||
[requirements],
|
||||
);
|
||||
|
||||
const [claimApplication] = useClaimApplicationMutation();
|
||||
const [completeReview] = useCompleteReviewMutation();
|
||||
const [requestAdjustment] = useRequestAdjustmentMutation();
|
||||
const [approveDocuments] = useApproveDocumentsMutation();
|
||||
@@ -531,8 +533,12 @@ export function LicenseReviewPage() {
|
||||
try {
|
||||
switch (action.id) {
|
||||
case "claim":
|
||||
// Claim is fired from the queue in practice; kept here for the case
|
||||
// where an officer opens an unclaimed application directly.
|
||||
// Usually fired from the queue, but an officer can also open an
|
||||
// unclaimed application directly and claim it from here.
|
||||
await run(
|
||||
() => claimApplication(id).unwrap(),
|
||||
t("review.done.claim", "Application claimed"),
|
||||
);
|
||||
break;
|
||||
case "complete-review":
|
||||
await run(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import {Alert, Badge, Button, Center, Container, Group, Loader, Modal, Paper, Stack, Table, Text, Textarea} from '@mantine/core';
|
||||
import { IconAlertTriangle, IconArrowLeft, IconCheck, IconDownload } from '@tabler/icons-react';
|
||||
import {Alert, Avatar, Badge, Button, Center, Container, Divider, Group, Loader, Modal, Paper, SimpleGrid, Stack, Text, Textarea, ThemeIcon, rem} from '@mantine/core';
|
||||
import { IconAlertTriangle, IconArrowLeft, IconCash, IconCheck, IconDownload, IconFileCertificate } from '@tabler/icons-react';
|
||||
import {
|
||||
SEAFARER_DOCUMENT_KIND_LABELS,
|
||||
SEAFARER_DOCUMENT_STATUS_COLORS,
|
||||
@@ -20,20 +20,35 @@ import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||
|
||||
const QUEUE_PATH = { SEAMAN_BOOK: '/seaman-book-queue', BTC_BASIC_TRAINING: '/btc-queue' } as const;
|
||||
|
||||
function Row({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
function Stat({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<Table.Tr>
|
||||
<Table.Td w="40%">
|
||||
<Text size="xs" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" component="div">
|
||||
{value ?? '—'}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
<div>
|
||||
<Text fz={10} fw={700} tt="uppercase" c="dimmed" lh={1.2} mb={3}>{label}</Text>
|
||||
<Text fz="sm" fw={600} component="div">{value ?? '—'}</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionCard({
|
||||
title,
|
||||
icon,
|
||||
color,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
color: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Group gap="xs" mb="sm">
|
||||
<ThemeIcon variant="light" color={color} size={26} radius="md">{icon}</ThemeIcon>
|
||||
<Text fw={700} fz="sm">{title}</Text>
|
||||
</Group>
|
||||
<Divider mb="md" />
|
||||
{children}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -168,61 +183,49 @@ export function SeafarerDocumentReviewPage() {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Paper withBorder p="lg" radius="md">
|
||||
<Stack gap="md">
|
||||
<Paper withBorder radius="md" p="lg" mb="md">
|
||||
<Group gap="md" wrap="nowrap">
|
||||
<Avatar size={52} radius="xl" color="blue" style={{ fontSize: rem(18) }}>
|
||||
{(applicant?.name ?? '??').split(' ').filter(Boolean).slice(0, 2).map((p) => p[0]).join('').toUpperCase()}
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text fw={600} size="sm" mb={4}>
|
||||
Seafarer
|
||||
</Text>
|
||||
<Table variant="vertical">
|
||||
<Table.Tbody>
|
||||
<Row label="Name" value={applicant?.name} />
|
||||
<Row label="Seafarer №" value={applicant?.seafarerNumber} />
|
||||
<Row
|
||||
label="Registration"
|
||||
value={
|
||||
applicant?.registrationId ? (
|
||||
<Link to={`/seafarer-registrations/${applicant.registrationId}`}>
|
||||
{applicant.registrationNumber}
|
||||
</Link>
|
||||
) : (
|
||||
applicant?.registrationNumber
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
<Text fw={700} fz="lg" lh={1.2}>{applicant?.name ?? '—'}</Text>
|
||||
<Group gap={6} mt={4}>
|
||||
<Text fz="xs" c="dimmed" ff="monospace">{applicant?.seafarerNumber ?? '—'}</Text>
|
||||
{applicant?.registrationId && (
|
||||
<>
|
||||
<Text fz="xs" c="dimmed">·</Text>
|
||||
<Link to={`/seafarer-registrations/${applicant.registrationId}`}>
|
||||
<Text fz="xs" c="blue.6">{applicant.registrationNumber}</Text>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</div>
|
||||
<div>
|
||||
<Text fw={600} size="sm" mb={4}>
|
||||
Payment
|
||||
</Text>
|
||||
<Table variant="vertical">
|
||||
<Table.Tbody>
|
||||
<Row label="Fee" value={document.feeAmount !== null ? `${document.feeAmount} ${document.feeCurrency}` : null} />
|
||||
<Row label="Released to payment" value={document.submittedAt ? showDate(document.submittedAt) : null} />
|
||||
<Row label="Paid" value={document.paidAt ? showDate(document.paidAt) : null} />
|
||||
<Row label="Provider" value={payment?.provider} />
|
||||
<Row label="Reference" value={document.paymentReference ?? payment?.providerRef} />
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</div>
|
||||
<div>
|
||||
<Text fw={600} size="sm" mb={4}>
|
||||
Issuance
|
||||
</Text>
|
||||
<Table variant="vertical">
|
||||
<Table.Tbody>
|
||||
<Row label="Pickup date" value={document.scheduledIssuanceDate ? showDate(document.scheduledIssuanceDate) : null} />
|
||||
<Row label="Document №" value={document.documentNumber} />
|
||||
<Row label="Issued" value={document.issueDate ? showDate(document.issueDate) : null} />
|
||||
<Row label="Valid until" value={document.expiryDate ? showDate(document.expiryDate) : null} />
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</div>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<SectionCard title="Payment" icon={<IconCash size={14} />} color="teal">
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<Stat label="Fee" value={document.feeAmount !== null ? `${document.feeAmount} ${document.feeCurrency}` : null} />
|
||||
<Stat label="Released to payment" value={document.submittedAt ? showDate(document.submittedAt) : null} />
|
||||
<Stat label="Paid" value={document.paidAt ? showDate(document.paidAt) : null} />
|
||||
<Stat label="Provider" value={payment?.provider} />
|
||||
<Stat label="Reference" value={document.paymentReference ?? payment?.providerRef} />
|
||||
</SimpleGrid>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title="Issuance" icon={<IconFileCertificate size={14} />} color="violet">
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<Stat label="Pickup date" value={document.scheduledIssuanceDate ? showDate(document.scheduledIssuanceDate) : null} />
|
||||
<Stat label="Document №" value={document.documentNumber} />
|
||||
<Stat label="Issued" value={document.issueDate ? showDate(document.issueDate) : null} />
|
||||
<Stat label="Valid until" value={document.expiryDate ? showDate(document.expiryDate) : null} />
|
||||
</SimpleGrid>
|
||||
</SectionCard>
|
||||
</SimpleGrid>
|
||||
|
||||
<Modal opened={scheduleOpen} onClose={() => setScheduleOpen(false)} title="Schedule pickup" centered>
|
||||
<Stack>
|
||||
<AmharicDatePicker label="Pickup date" dateFormat="date" value={pickupDate} onChange={setPickupDate} required />
|
||||
|
||||
@@ -2,7 +2,7 @@ import { PageHeader, StatusBadge } from '@ema-platform/ui';
|
||||
import { type StatusTone } from '@ema-platform/shared';
|
||||
import { useState } from 'react';
|
||||
import { useApiQuery } from '@ema-platform/api';
|
||||
import {ActionIcon, Badge, Button, Card, Collapse, Divider, Group, Modal, Paper, Select, SimpleGrid, Stack, Table, Text, TextInput, ThemeIcon, rem} from '@mantine/core';
|
||||
import {ActionIcon, Avatar, Badge, Button, Card, Collapse, Divider, Group, Modal, Paper, Select, SimpleGrid, Stack, Table, Text, TextInput, ThemeIcon, rem} from '@mantine/core';
|
||||
import {
|
||||
IconBook2,
|
||||
IconCertificate,
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
IconShieldCheck,
|
||||
IconUser,
|
||||
IconUsers,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -47,66 +48,72 @@ const STATUS_TONE: Record<string, StatusTone> = { Active: 'success', Inactive: '
|
||||
// ---------------------------------------------------------------------------
|
||||
// Detail modal
|
||||
// ---------------------------------------------------------------------------
|
||||
function Stat({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<Text fz={10} fw={700} tt="uppercase" c="dimmed" lh={1.2} mb={3}>{label}</Text>
|
||||
<Text fz="sm" fw={600}>{value ?? '—'}</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailModal({ sf, opened, onClose }: { sf: Seafarer | null; opened: boolean; onClose: () => void }) {
|
||||
if (!sf) return null;
|
||||
const initials = sf.name.split(' ').filter(Boolean).slice(0, 2).map((p) => p[0]).join('').toUpperCase();
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={<Group gap="xs"><IconUser size={17} /><Text fw={700}>{sf.name} — {sf.id}</Text></Group>}
|
||||
size="xl"
|
||||
radius="lg"
|
||||
>
|
||||
<Stack gap="lg">
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fz="xs" fw={700} c="dimmed" mb="xs" style={{ textTransform: 'uppercase' }}>Personal</Text>
|
||||
<Stack gap={4}>
|
||||
<Group justify="space-between"><Text fz="xs" c="dimmed">Full Name</Text><Text fz="xs" fw={600}>{sf.name}</Text></Group>
|
||||
<Group justify="space-between"><Text fz="xs" c="dimmed">Nationality</Text><Text fz="xs">{sf.nationality}</Text></Group>
|
||||
<Group justify="space-between"><Text fz="xs" c="dimmed">Date of Birth</Text><Text fz="xs">{sf.dob}</Text></Group>
|
||||
<Group justify="space-between"><Text fz="xs" c="dimmed">Rank</Text><Text fz="xs" fw={600}>{sf.rank}</Text></Group>
|
||||
<Group justify="space-between"><Text fz="xs" c="dimmed">Status</Text><StatusBadge tone={STATUS_TONE[sf.status]} label={sf.status} variant="light" size="xs" /></Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fz="xs" fw={700} c="dimmed" mb="xs" style={{ textTransform: 'uppercase' }}>Documents</Text>
|
||||
<Stack gap={4}>
|
||||
<Group justify="space-between"><Text fz="xs" c="dimmed">Seaman Book</Text><Text fz="xs" fw={600}>{sf.seamanBookNo}</Text></Group>
|
||||
<Group justify="space-between"><Text fz="xs" c="dimmed">SB Expiry</Text><Text fz="xs">{sf.seamanBookExpiry}</Text></Group>
|
||||
<Group justify="space-between"><Text fz="xs" c="dimmed">BTC No.</Text><Text fz="xs">{sf.btcNo ?? '—'}</Text></Group>
|
||||
<Group justify="space-between"><Text fz="xs" c="dimmed">BSID No.</Text><Text fz="xs">{sf.bsidNo ?? '—'}</Text></Group>
|
||||
<Group justify="space-between">
|
||||
<Text fz="xs" c="dimmed">Medical Expiry</Text>
|
||||
<Badge color={MEDICAL_COLOR[sf.medicalStatus]} variant="light" size="xs">{sf.medicalExpiry} ({sf.medicalStatus})</Badge>
|
||||
<Modal opened={opened} onClose={onClose} size="xl" radius="lg" padding={0} withCloseButton={false}>
|
||||
<Stack gap={0}>
|
||||
{/* Header */}
|
||||
<Group justify="space-between" wrap="nowrap" p="lg" style={{ borderBottom: '1px solid var(--mantine-color-default-border)' }}>
|
||||
<Group gap="md" wrap="nowrap">
|
||||
<Avatar size={52} radius="xl" color="blue" style={{ fontSize: rem(18) }}>{initials}</Avatar>
|
||||
<div>
|
||||
<Text fw={700} fz="lg" lh={1.2}>{sf.name}</Text>
|
||||
<Group gap={6} mt={4}>
|
||||
<Text fz="xs" c="dimmed" ff="monospace">{sf.id}</Text>
|
||||
<Text fz="xs" c="dimmed">·</Text>
|
||||
<Text fz="xs" c="dimmed">{sf.rank}</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</SimpleGrid>
|
||||
</div>
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
<StatusBadge tone={STATUS_TONE[sf.status]} label={sf.status} variant="light" />
|
||||
<ActionIcon variant="subtle" color="gray" onClick={onClose}><IconX size={16} /></ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{sf.cocCerts.length > 0 && (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fz="xs" fw={700} c="dimmed" mb="xs" style={{ textTransform: 'uppercase' }}>CoC / CoP Certificates</Text>
|
||||
<Table fz="xs" verticalSpacing="xs">
|
||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
||||
<Table.Tr>
|
||||
{['Type', 'Certificate No.', 'Expiry'].map((h) => (
|
||||
<Table.Th key={h} style={{ fontSize: rem(10), textTransform: 'uppercase', color: 'var(--mantine-color-dimmed)' }}>{h}</Table.Th>
|
||||
))}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
<Stack gap="lg" p="lg">
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="lg">
|
||||
<Stat label="Nationality" value={sf.nationality} />
|
||||
<Stat label="Date of Birth" value={sf.dob} />
|
||||
<Stat label="Seaman Book №" value={sf.seamanBookNo} />
|
||||
<Stat label="SB Expiry" value={sf.seamanBookExpiry} />
|
||||
<Stat label="BTC №" value={sf.btcNo ?? <Badge color="red" variant="light" size="xs">Missing</Badge>} />
|
||||
<Stat label="BSID №" value={sf.bsidNo ?? <Badge color="red" variant="light" size="xs">Missing</Badge>} />
|
||||
<Stat label="Medical" value={<Badge color={MEDICAL_COLOR[sf.medicalStatus]} variant="light" size="sm">{sf.medicalStatus}</Badge>} />
|
||||
<Stat label="Medical Expiry" value={sf.medicalExpiry} />
|
||||
</SimpleGrid>
|
||||
|
||||
{sf.cocCerts.length > 0 && (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fz="xs" fw={700} c="dimmed" mb="sm" tt="uppercase">CoC / CoP Certificates</Text>
|
||||
<Stack gap="xs">
|
||||
{sf.cocCerts.map((c) => (
|
||||
<Table.Tr key={c.no}>
|
||||
<Table.Td><Text fz="xs" fw={600}>{c.type}</Text></Table.Td>
|
||||
<Table.Td><Text fz="xs" c="blue.7">{c.no}</Text></Table.Td>
|
||||
<Table.Td><Text fz="xs">{c.expiry}</Text></Table.Td>
|
||||
</Table.Tr>
|
||||
<Group key={c.no} justify="space-between" wrap="nowrap" p="xs" style={{ borderRadius: 8, background: 'var(--mantine-color-default-hover)' }}>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<ThemeIcon variant="light" color="violet" size={30} radius="md"><IconCertificate size={15} /></ThemeIcon>
|
||||
<div>
|
||||
<Text fz="sm" fw={600}>{c.type}</Text>
|
||||
<Text fz="xs" c="dimmed" ff="monospace">{c.no}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Text fz="xs" c="dimmed">Expires {c.expiry}</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user