This commit is contained in:
Nati
2026-08-21 18:17:52 +00:00
parent 56cb22c7bd
commit 9f4954b5e8
3 changed files with 138 additions and 122 deletions

View File

@@ -39,6 +39,7 @@ import {
useLocalized, useLocalized,
useApproveDocumentsMutation, useApproveDocumentsMutation,
useAssignApplicationMutation, useAssignApplicationMutation,
useClaimApplicationMutation,
useCompleteReviewMutation, useCompleteReviewMutation,
useConfirmPaymentMutation, useConfirmPaymentMutation,
useScheduleIssuanceMutation, useScheduleIssuanceMutation,
@@ -198,6 +199,7 @@ export function LicenseReviewPage() {
[requirements], [requirements],
); );
const [claimApplication] = useClaimApplicationMutation();
const [completeReview] = useCompleteReviewMutation(); const [completeReview] = useCompleteReviewMutation();
const [requestAdjustment] = useRequestAdjustmentMutation(); const [requestAdjustment] = useRequestAdjustmentMutation();
const [approveDocuments] = useApproveDocumentsMutation(); const [approveDocuments] = useApproveDocumentsMutation();
@@ -531,8 +533,12 @@ export function LicenseReviewPage() {
try { try {
switch (action.id) { switch (action.id) {
case "claim": case "claim":
// Claim is fired from the queue in practice; kept here for the case // Usually fired from the queue, but an officer can also open an
// where an officer opens an unclaimed application directly. // unclaimed application directly and claim it from here.
await run(
() => claimApplication(id).unwrap(),
t("review.done.claim", "Application claimed"),
);
break; break;
case "complete-review": case "complete-review":
await run( await run(

View File

@@ -1,7 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom'; 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 {Alert, Avatar, Badge, Button, Center, Container, Divider, Group, Loader, Modal, Paper, SimpleGrid, Stack, Text, Textarea, ThemeIcon, rem} from '@mantine/core';
import { IconAlertTriangle, IconArrowLeft, IconCheck, IconDownload } from '@tabler/icons-react'; import { IconAlertTriangle, IconArrowLeft, IconCash, IconCheck, IconDownload, IconFileCertificate } from '@tabler/icons-react';
import { import {
SEAFARER_DOCUMENT_KIND_LABELS, SEAFARER_DOCUMENT_KIND_LABELS,
SEAFARER_DOCUMENT_STATUS_COLORS, 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; 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 ( return (
<Table.Tr> <div>
<Table.Td w="40%"> <Text fz={10} fw={700} tt="uppercase" c="dimmed" lh={1.2} mb={3}>{label}</Text>
<Text size="xs" c="dimmed"> <Text fz="sm" fw={600} component="div">{value ?? '—'}</Text>
{label} </div>
</Text> );
</Table.Td> }
<Table.Td>
<Text size="sm" component="div"> function SectionCard({
{value ?? '—'} title,
</Text> icon,
</Table.Td> color,
</Table.Tr> 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> </Alert>
)} )}
<Paper withBorder p="lg" radius="md"> <Paper withBorder radius="md" p="lg" mb="md">
<Stack gap="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> <div>
<Text fw={600} size="sm" mb={4}> <Text fw={700} fz="lg" lh={1.2}>{applicant?.name ?? '—'}</Text>
Seafarer <Group gap={6} mt={4}>
</Text> <Text fz="xs" c="dimmed" ff="monospace">{applicant?.seafarerNumber ?? '—'}</Text>
<Table variant="vertical"> {applicant?.registrationId && (
<Table.Tbody> <>
<Row label="Name" value={applicant?.name} /> <Text fz="xs" c="dimmed">·</Text>
<Row label="Seafarer №" value={applicant?.seafarerNumber} /> <Link to={`/seafarer-registrations/${applicant.registrationId}`}>
<Row <Text fz="xs" c="blue.6">{applicant.registrationNumber}</Text>
label="Registration" </Link>
value={ </>
applicant?.registrationId ? ( )}
<Link to={`/seafarer-registrations/${applicant.registrationId}`}> </Group>
{applicant.registrationNumber}
</Link>
) : (
applicant?.registrationNumber
)
}
/>
</Table.Tbody>
</Table>
</div> </div>
<div> </Group>
<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>
</Paper> </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> <Modal opened={scheduleOpen} onClose={() => setScheduleOpen(false)} title="Schedule pickup" centered>
<Stack> <Stack>
<AmharicDatePicker label="Pickup date" dateFormat="date" value={pickupDate} onChange={setPickupDate} required /> <AmharicDatePicker label="Pickup date" dateFormat="date" value={pickupDate} onChange={setPickupDate} required />

View File

@@ -2,7 +2,7 @@ import { PageHeader, StatusBadge } from '@ema-platform/ui';
import { type StatusTone } from '@ema-platform/shared'; import { type StatusTone } from '@ema-platform/shared';
import { useState } from 'react'; import { useState } from 'react';
import { useApiQuery } from '@ema-platform/api'; 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 { import {
IconBook2, IconBook2,
IconCertificate, IconCertificate,
@@ -15,6 +15,7 @@ import {
IconShieldCheck, IconShieldCheck,
IconUser, IconUser,
IconUsers, IconUsers,
IconX,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -47,66 +48,72 @@ const STATUS_TONE: Record<string, StatusTone> = { Active: 'success', Inactive: '
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Detail modal // 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 }) { function DetailModal({ sf, opened, onClose }: { sf: Seafarer | null; opened: boolean; onClose: () => void }) {
if (!sf) return null; if (!sf) return null;
const initials = sf.name.split(' ').filter(Boolean).slice(0, 2).map((p) => p[0]).join('').toUpperCase();
return ( return (
<Modal <Modal opened={opened} onClose={onClose} size="xl" radius="lg" padding={0} withCloseButton={false}>
opened={opened} <Stack gap={0}>
onClose={onClose} {/* Header */}
title={<Group gap="xs"><IconUser size={17} /><Text fw={700}>{sf.name} {sf.id}</Text></Group>} <Group justify="space-between" wrap="nowrap" p="lg" style={{ borderBottom: '1px solid var(--mantine-color-default-border)' }}>
size="xl" <Group gap="md" wrap="nowrap">
radius="lg" <Avatar size={52} radius="xl" color="blue" style={{ fontSize: rem(18) }}>{initials}</Avatar>
> <div>
<Stack gap="lg"> <Text fw={700} fz="lg" lh={1.2}>{sf.name}</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md"> <Group gap={6} mt={4}>
<Paper withBorder radius="md" p="md"> <Text fz="xs" c="dimmed" ff="monospace">{sf.id}</Text>
<Text fz="xs" fw={700} c="dimmed" mb="xs" style={{ textTransform: 'uppercase' }}>Personal</Text> <Text fz="xs" c="dimmed">·</Text>
<Stack gap={4}> <Text fz="xs" c="dimmed">{sf.rank}</Text>
<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>
</Group> </Group>
</Stack> </div>
</Paper> </Group>
</SimpleGrid> <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 && ( <Stack gap="lg" p="lg">
<Paper withBorder radius="md" p="md"> <SimpleGrid cols={{ base: 2, sm: 4 }} spacing="lg">
<Text fz="xs" fw={700} c="dimmed" mb="xs" style={{ textTransform: 'uppercase' }}>CoC / CoP Certificates</Text> <Stat label="Nationality" value={sf.nationality} />
<Table fz="xs" verticalSpacing="xs"> <Stat label="Date of Birth" value={sf.dob} />
<Table.Thead bg="var(--mantine-color-default-hover)"> <Stat label="Seaman Book №" value={sf.seamanBookNo} />
<Table.Tr> <Stat label="SB Expiry" value={sf.seamanBookExpiry} />
{['Type', 'Certificate No.', 'Expiry'].map((h) => ( <Stat label="BTC №" value={sf.btcNo ?? <Badge color="red" variant="light" size="xs">Missing</Badge>} />
<Table.Th key={h} style={{ fontSize: rem(10), textTransform: 'uppercase', color: 'var(--mantine-color-dimmed)' }}>{h}</Table.Th> <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>} />
</Table.Tr> <Stat label="Medical Expiry" value={sf.medicalExpiry} />
</Table.Thead> </SimpleGrid>
<Table.Tbody>
{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) => ( {sf.cocCerts.map((c) => (
<Table.Tr key={c.no}> <Group key={c.no} justify="space-between" wrap="nowrap" p="xs" style={{ borderRadius: 8, background: 'var(--mantine-color-default-hover)' }}>
<Table.Td><Text fz="xs" fw={600}>{c.type}</Text></Table.Td> <Group gap="xs" wrap="nowrap">
<Table.Td><Text fz="xs" c="blue.7">{c.no}</Text></Table.Td> <ThemeIcon variant="light" color="violet" size={30} radius="md"><IconCertificate size={15} /></ThemeIcon>
<Table.Td><Text fz="xs">{c.expiry}</Text></Table.Td> <div>
</Table.Tr> <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> </Stack>
</Table> </Paper>
</Paper> )}
)} </Stack>
</Stack> </Stack>
</Modal> </Modal>
); );