Files
emaui/apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationReviewPage.tsx
2026-07-24 09:25:38 +00:00

365 lines
15 KiB
TypeScript

import { useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import {
ActionIcon,
Alert,
Badge,
Box,
Button,
Card,
Divider,
Grid,
Group,
Modal,
MultiSelect,
Paper,
SimpleGrid,
Stack,
Stepper,
Tabs,
Text,
Textarea,
ThemeIcon,
Title,
rem,
} from '@mantine/core';
import {
IconAlertTriangle,
IconArrowLeft,
IconCircleCheck,
IconClock,
IconDownload,
IconExternalLink,
IconEye,
IconFileDescription,
IconFiles,
IconInfoCircle,
IconUserQuestion,
IconX,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import {
applyDecision,
generateCertificates,
MOCK_REGISTRATIONS,
STATUS_COLOR,
type RegistrationDocument,
type RegistrationStatus,
} from '../mock';
// ponytail: view/download hit this demo data-URI, wire to real file storage when backend lands.
const DEMO_PDF = 'data:application/pdf;base64,JVBERi0xLjQKJeLjz9MKMSAwIG9iago8PC9UeXBlL0NhdGFsb2cvUGFnZXMgMiAwIFI+PgplbmRvYmoKMiAwIG9iago8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PgplbmRvYmoKMyAwIG9iago8PC9UeXBlL1BhZ2UvUGFyZW50IDIgMCBSL01lZGlhQm94WzAgMCA2MTIgNzkyXT4+CmVuZG9iagp4cmVmCjAgNAowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMDkgMDAwMDAgbiAKMDAwMDAwMDA1OCAwMDAwMCBuIAowMDAwMDAwMTE1IDAwMDAwIG4gCnRyYWlsZXIKPDwvU2l6ZSA0L1Jvb3QgMSAwIFI+PgpzdGFydHhyZWYKMjIwCiUlRU9G';
function getDocUrl(fileName: string) {
return fileName.endsWith('.pdf') ? DEMO_PDF : `https://placehold.co/600x400/e9ecef/6c757d?text=${encodeURIComponent(fileName)}`;
}
function DocViewer({ label, fileName, fileType }: RegistrationDocument) {
const [open, setOpen] = useState(false);
const url = getDocUrl(fileName);
return (
<>
<Card withBorder radius="md" p="sm">
<Group gap="sm" wrap="nowrap" mb="sm">
<ThemeIcon size="md" variant="light" color={fileType === 'pdf' ? '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={fileType === 'pdf' ? 'red' : 'blue'}>{fileType.toUpperCase()}</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)' }}>
{fileType === 'pdf'
? <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>
<Group grow mt="xs">
<Button size="xs" variant="subtle" leftSection={<IconEye size={13} />} rightSection={<IconExternalLink size={13} />} onClick={() => setOpen(true)}>
View
</Button>
<Button size="xs" variant="light" component="a" href={url} download={fileName} leftSection={<IconDownload size={13} />}>
Download
</Button>
</Group>
</Card>
<Modal opened={open} onClose={() => setOpen(false)} title={<Text fw={700}>{label} {fileName}</Text>} size="90vw" styles={{ body: { padding: 0, height: '80vh' } }}>
{fileType === 'pdf'
? <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>
);
}
export function VesselRegistrationReviewPage() {
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const record = MOCK_REGISTRATIONS.find((r) => r.id === id);
const [status, setStatus] = useState<RegistrationStatus | undefined>(record?.status);
const [remarks, setRemarks] = useState(record?.remarks ?? '');
const [correctionFields, setCorrectionFields] = useState<string[]>(record?.correctionFields ?? []);
const [, forceUpdate] = useState(0);
if (!record || !status) {
return (
<Stack gap="md">
<Group gap="sm">
<ActionIcon variant="subtle" size="lg" onClick={() => navigate('/vessel-registrations')}><IconArrowLeft size={18} /></ActionIcon>
<Title order={3}>Vessel Registration</Title>
</Group>
<Alert color="red" icon={<IconInfoCircle size={16} />}>Registration not found.</Alert>
</Stack>
);
}
const isTerminal = status === 'Approved' || status === 'Rejected';
const decide = (newStatus: RegistrationStatus, label: string) => {
applyDecision(record, newStatus, remarks, newStatus === 'Correction Required' ? correctionFields : undefined);
if (newStatus === 'Approved') generateCertificates(record);
setStatus(newStatus);
forceUpdate((n) => n + 1);
notify.success(`Registration ${label}. SMS and email notification sent.`);
};
return (
<Stack gap="md">
{/* Header */}
<Group justify="space-between">
<Group gap="sm">
<ActionIcon variant="subtle" size="lg" onClick={() => navigate('/vessel-registrations')}>
<IconArrowLeft size={18} />
</ActionIcon>
<div>
<Title order={3}>Registration Review {record.vesselName}</Title>
<Group gap={6} mt={2}>
<Text fz="sm" c="dimmed">{record.id}</Text>
<Text fz="sm" c="dimmed">·</Text>
<Text fz="sm" c="dimmed">Submitted {record.submitted}</Text>
</Group>
</div>
</Group>
<Badge size="lg" variant="light" color={STATUS_COLOR[status]}>{status}</Badge>
</Group>
{/* Decision bar / terminal notice */}
{!isTerminal ? (
<Paper withBorder radius="lg" p="md" bg="gray.0">
<Stack gap="sm">
<Textarea
label="Officer Remarks"
placeholder="Add notes, or the reason for correction / rejection…"
minRows={2}
value={remarks}
onChange={(e) => setRemarks(e.currentTarget.value)}
/>
<MultiSelect
label="Fields / Documents Requiring Correction (optional)"
placeholder="Select fields or documents…"
data={[...record.documents.map((d) => d.label), 'Vessel Details', 'Technical Specifications', 'Ownership Information']}
value={correctionFields}
onChange={setCorrectionFields}
clearable
/>
<Group gap="sm" justify="flex-end">
<Button
size="sm"
color="blue"
variant="light"
leftSection={<IconUserQuestion size={15} />}
onClick={() => decide('Under Review', 'marked under review')}
disabled={status === 'Under Review'}
>
Mark Under Review
</Button>
<Button
size="sm"
color="orange"
variant="light"
leftSection={<IconAlertTriangle size={15} />}
onClick={() => decide('Correction Required', 'sent back for correction')}
disabled={!remarks.trim()}
>
Request Correction
</Button>
<Button
size="sm"
color="red"
variant="light"
leftSection={<IconX size={15} />}
onClick={() => decide('Rejected', 'rejected')}
disabled={!remarks.trim()}
>
Reject
</Button>
<Button
size="sm"
color="teal"
leftSection={<IconCircleCheck size={15} />}
onClick={() => decide('Approved', 'approved')}
>
Approve
</Button>
</Group>
{!remarks.trim() && (
<Text fz="xs" c="dimmed" ta="right">Remarks are required to request correction or reject this application.</Text>
)}
</Stack>
</Paper>
) : (
<Alert
variant="light"
color={STATUS_COLOR[status]}
icon={status === 'Approved' ? <IconCircleCheck size={17} /> : <IconAlertTriangle size={17} />}
>
{status === 'Approved'
? <>Registration approved. Certificates generated for {record.owner.name}.</>
: <>This registration was rejected. {record.remarks}</>}
</Alert>
)}
{/* Timeline */}
<Paper withBorder radius="lg" p="xl">
<Text fw={700} mb="md">Timeline</Text>
<Stepper active={record.timeline.filter((t) => t.done).length - 1} size="sm" color="teal" orientation="horizontal">
{record.timeline.map((step, i) => (
<Stepper.Step
key={i}
label={step.event}
description={step.date ?? 'Pending'}
icon={step.done ? <IconCircleCheck size={16} /> : <IconClock size={16} />}
/>
))}
</Stepper>
</Paper>
{/* Two-column body */}
<Grid gutter="md">
{/* Left column */}
<Grid.Col span={{ base: 12, lg: 7 }}>
<Tabs defaultValue="information" variant="outline">
<Tabs.List mb="md">
<Tabs.Tab value="information" leftSection={<IconInfoCircle size={15} />}>Information</Tabs.Tab>
<Tabs.Tab value="documents" leftSection={<IconFiles size={15} />}>Documents</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="information">
<Stack gap="md">
<Paper withBorder radius="lg" p="xl">
<Text fw={700} mb="md">Vessel Information</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<InfoRow label="Vessel Name" value={record.vesselName} />
<InfoRow label="Vessel Type" value={record.vesselType} />
<InfoRow label="Category" value={record.category} />
<InfoRow label="Registration Area" value={record.registrationArea} />
<InfoRow label="Flag State" value={record.flagState} />
<InfoRow
label={record.category === 'Sea-going' ? 'Gross Tonnage (GT)' : 'Passenger Capacity'}
value={(record.category === 'Sea-going' ? record.grossTonnage : record.passengerCapacity) ?? ''}
/>
<InfoRow label="Length / Breadth / Depth" value={`${record.length} / ${record.breadth} / ${record.depth}`} />
</SimpleGrid>
</Paper>
<Paper withBorder radius="lg" p="xl">
<Text fw={700} mb="md">Technical Specifications</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<InfoRow
label={record.category === 'Sea-going' ? 'IMO Number' : 'Hull Number'}
value={(record.category === 'Sea-going' ? record.imoNumber : record.hullNumber) ?? ''}
/>
<InfoRow label="Shipyard" value={record.shipyard} />
<InfoRow label="Year Built" value={record.yearBuilt} />
<InfoRow label="Engine Type" value={record.engineType} />
<InfoRow label="Engine Number" value={record.engineNumber} />
<InfoRow label="Engine Power" value={record.enginePower} />
<InfoRow label="Hull Material" value={record.hullMaterial} />
</SimpleGrid>
</Paper>
<Paper withBorder radius="lg" p="xl">
<Text fw={700} mb="md">Ownership Information</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<InfoRow label="Owner Name" value={record.owner.name} />
<InfoRow label="National ID / TIN" value={record.owner.idOrTin} />
<InfoRow label="Phone Number" value={record.owner.phone} />
<InfoRow label="Email Address" value={record.owner.email} />
<InfoRow label="Address" value={record.owner.address} />
</SimpleGrid>
</Paper>
</Stack>
</Tabs.Panel>
<Tabs.Panel value="documents">
<Paper withBorder radius="lg" p="xl">
<Text fw={700} mb="md">Documents</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
{record.documents.map((doc) => <DocViewer key={doc.key} label={doc.label} fileName={doc.fileName} fileType={doc.fileType} />)}
</SimpleGrid>
</Paper>
</Tabs.Panel>
</Tabs>
</Grid.Col>
{/* Right column */}
<Grid.Col span={{ base: 12, lg: 5 }}>
<Stack gap="md">
{(record.remarks || (record.correctionFields && record.correctionFields.length > 0)) && (
<Paper withBorder radius="lg" p="xl">
<Text fw={700} mb="sm">Officer Remarks</Text>
{record.remarks && <Text fz="sm" mb="sm">{record.remarks}</Text>}
{record.correctionFields && record.correctionFields.length > 0 && (
<Group gap={6}>
{record.correctionFields.map((f) => <Badge key={f} size="sm" color="orange" variant="light">{f}</Badge>)}
</Group>
)}
</Paper>
)}
{status === 'Approved' && record.certificates && (
<Paper withBorder radius="lg" p="xl">
<Text fw={700} mb="md">Certificates</Text>
<Stack gap="sm">
{record.certificates.map((cert) => (
<div key={cert.name}>
<Group justify="space-between" wrap="wrap" gap="xs">
<div>
<Text fw={600} fz="sm">{cert.name}</Text>
<Text fz="xs" c="dimmed">No. {cert.number} Issued {cert.issueDate}</Text>
</div>
<Button
size="xs"
variant="light"
leftSection={<IconDownload size={13} />}
component="a"
href={DEMO_PDF}
download={`${cert.name.replace(/\s+/g, '-')}-${cert.number}.pdf`}
>
Download
</Button>
</Group>
<Divider mt="sm" />
</div>
))}
</Stack>
</Paper>
)}
</Stack>
</Grid.Col>
</Grid>
</Stack>
);
}