mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
merge fixes
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { AppShell, rem } from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { logout } from '@ema-platform/auth';
|
||||
import { AppHeader, AppSidebar } from '@ema-platform/ui';
|
||||
import type { NavItem } from '@ema-platform/ui';
|
||||
import { useCallback, useState } from "react";
|
||||
import { AppShell, rem } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { logout } from "@ema-platform/auth";
|
||||
import { AppHeader, AppSidebar } from "@ema-platform/ui";
|
||||
import type { NavItem } from "@ema-platform/ui";
|
||||
import {
|
||||
IconAnchor,
|
||||
IconBook2,
|
||||
@@ -24,49 +24,104 @@ import {
|
||||
IconQuestionMark,
|
||||
IconClipboardList,
|
||||
IconReport,
|
||||
IconAnchor,
|
||||
IconFilePlus,
|
||||
IconGauge,
|
||||
IconShieldOff,
|
||||
IconShip,
|
||||
IconStack2,
|
||||
IconTruck,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { SUPPORTED_LANGUAGES } from '../i18n/config';
|
||||
import { useAppDispatch, useAppSelector } from '../store/hooks';
|
||||
IconReportAnalytics,
|
||||
} from "@tabler/icons-react";
|
||||
import { notify } from "@ema-platform/ui";
|
||||
import { SUPPORTED_LANGUAGES } from "../i18n/config";
|
||||
import { useAppDispatch, useAppSelector } from "../store/hooks";
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ to: '/dashboard', label: 'nav.dashboard', icon: IconLayoutDashboard },
|
||||
{ to: '/um/user-management/dashboard', label: 'nav.userManagement', icon: IconUserShield },
|
||||
{ to: '/seaman-book-queue', label: 'nav.seamanBookQueue', icon: IconBook2 },
|
||||
{ to: '/vessel-registration-head-dashboard', label: 'nav.vesselRegistrationHeadDashboard', icon: IconGauge },
|
||||
{ to: '/vessel-registration-queue', label: 'nav.vesselRegistrationQueue', icon: IconAnchor },
|
||||
{ to: '/vessel-registration-queue/new', label: 'nav.vesselFormBuilder', icon: IconFilePlus },
|
||||
{ to: '/vessel-registration-report', label: 'nav.vesselRegistrationReport', icon: IconChartBar },
|
||||
{ to: '/vessel-ownership-transfer', label: 'nav.ownershipTransferQueue', icon: IconFileDescription },
|
||||
{ to: '/logistics-head-dashboard', label: 'nav.logisticsHeadDashboard', icon: IconGauge },
|
||||
{ to: '/freight-forwarder-license', label: 'nav.freightForwarderLicense', icon: IconTruck },
|
||||
{ to: '/shipping-agent-license', label: 'nav.shippingAgentLicense', icon: IconShip },
|
||||
{ to: '/combined-license', label: 'nav.combinedLicense', icon: IconStack2 },
|
||||
{ to: '/joint-investment-license', label: 'nav.jointInvestmentLicense', icon: IconUsers },
|
||||
{ to: '/mto-license', label: 'nav.mtoLicense', icon: IconTruck },
|
||||
{ to: '/waiver', label: 'nav.waiver', icon: IconShieldOff },
|
||||
{ to: '/coc-queue', label: 'nav.cocQueue', icon: IconShieldCheck },
|
||||
{ to: '/endorsement-queue', label: 'nav.endorsementQueue', icon: IconRubberStamp },
|
||||
{ to: '/vessel-registrations', label: 'nav.vesselRegistrations', icon: IconAnchor },
|
||||
{ to: '/vessel-registration-report', label: 'nav.vesselRegistrationReport', icon: IconReportAnalytics },
|
||||
{ to: '/vessel-transfers', label: 'nav.vesselTransfers', icon: IconShip },
|
||||
{ to: '/seafarer-registry', label: 'nav.seafarerRegistry', icon: IconUsers },
|
||||
{ to: "/dashboard", label: "nav.dashboard", icon: IconLayoutDashboard },
|
||||
{
|
||||
to: "/um/user-management/dashboard",
|
||||
label: "nav.userManagement",
|
||||
icon: IconUserShield,
|
||||
},
|
||||
{ to: "/seaman-book-queue", label: "nav.seamanBookQueue", icon: IconBook2 },
|
||||
{
|
||||
to: "/vessel-registration-head-dashboard",
|
||||
label: "nav.vesselRegistrationHeadDashboard",
|
||||
icon: IconGauge,
|
||||
},
|
||||
{
|
||||
to: "/vessel-registration-queue",
|
||||
label: "nav.vesselRegistrationQueue",
|
||||
icon: IconAnchor,
|
||||
},
|
||||
{
|
||||
to: "/vessel-registration-queue/new",
|
||||
label: "nav.vesselFormBuilder",
|
||||
icon: IconFilePlus,
|
||||
},
|
||||
{
|
||||
to: "/vessel-registration-report",
|
||||
label: "nav.vesselRegistrationReport",
|
||||
icon: IconChartBar,
|
||||
},
|
||||
{
|
||||
to: "/vessel-ownership-transfer",
|
||||
label: "nav.ownershipTransferQueue",
|
||||
icon: IconFileDescription,
|
||||
},
|
||||
{
|
||||
to: "/logistics-head-dashboard",
|
||||
label: "nav.logisticsHeadDashboard",
|
||||
icon: IconGauge,
|
||||
},
|
||||
{
|
||||
to: "/freight-forwarder-license",
|
||||
label: "nav.freightForwarderLicense",
|
||||
icon: IconTruck,
|
||||
},
|
||||
{
|
||||
to: "/shipping-agent-license",
|
||||
label: "nav.shippingAgentLicense",
|
||||
icon: IconShip,
|
||||
},
|
||||
{ to: "/combined-license", label: "nav.combinedLicense", icon: IconStack2 },
|
||||
{
|
||||
to: "/joint-investment-license",
|
||||
label: "nav.jointInvestmentLicense",
|
||||
icon: IconUsers,
|
||||
},
|
||||
{ to: "/mto-license", label: "nav.mtoLicense", icon: IconTruck },
|
||||
{ to: "/waiver", label: "nav.waiver", icon: IconShieldOff },
|
||||
{ to: "/coc-queue", label: "nav.cocQueue", icon: IconShieldCheck },
|
||||
{
|
||||
to: "/endorsement-queue",
|
||||
label: "nav.endorsementQueue",
|
||||
icon: IconRubberStamp,
|
||||
},
|
||||
{
|
||||
to: "/vessel-registrations",
|
||||
label: "nav.vesselRegistrations",
|
||||
icon: IconAnchor,
|
||||
},
|
||||
{
|
||||
to: "/vessel-registration-report",
|
||||
label: "nav.vesselRegistrationReport",
|
||||
icon: IconReportAnalytics,
|
||||
},
|
||||
{ to: "/vessel-transfers", label: "nav.vesselTransfers", icon: IconShip },
|
||||
{ to: "/seafarer-registry", label: "nav.seafarerRegistry", icon: IconUsers },
|
||||
// { to: '/applications', label: 'nav.applications', icon: IconFileDescription },
|
||||
{ to: '/payment-config', label: 'nav.paymentConfig', icon: IconCreditCard },
|
||||
{ to: '/analytics', label: 'nav.analytics', icon: IconChartBar },
|
||||
{ to: '/medical-verification', label: 'nav.medicalVerification', icon: IconHeart },
|
||||
{ to: '/questions', label: 'nav.questions', icon: IconQuestionMark },
|
||||
{ to: '/exams', label: 'nav.exams', icon: IconClipboardList },
|
||||
{ to: '/exam-results', label: 'nav.examResults', icon: IconReport },
|
||||
{ to: '/configuration', label: 'nav.configuration', icon: IconSettings },
|
||||
{ to: '/profile', label: 'nav.profile', icon: IconUser },
|
||||
{ to: "/payment-config", label: "nav.paymentConfig", icon: IconCreditCard },
|
||||
{ to: "/analytics", label: "nav.analytics", icon: IconChartBar },
|
||||
{
|
||||
to: "/medical-verification",
|
||||
label: "nav.medicalVerification",
|
||||
icon: IconHeart,
|
||||
},
|
||||
{ to: "/questions", label: "nav.questions", icon: IconQuestionMark },
|
||||
{ to: "/exams", label: "nav.exams", icon: IconClipboardList },
|
||||
{ to: "/exam-results", label: "nav.examResults", icon: IconReport },
|
||||
{ to: "/configuration", label: "nav.configuration", icon: IconSettings },
|
||||
{ to: "/profile", label: "nav.profile", icon: IconUser },
|
||||
];
|
||||
|
||||
const HEADER_HEIGHT = 116;
|
||||
@@ -81,24 +136,29 @@ export function BackofficeLayout() {
|
||||
const user = useAppSelector((state) => state.auth.user);
|
||||
const layoutMode = useAppSelector((state) => state.preferences.layoutMode);
|
||||
|
||||
const displayName = user?.name?.en || user?.username || '';
|
||||
const displayName = user?.name?.en || user?.username || "";
|
||||
const initials = displayName
|
||||
? displayName.split(/\s+/).map((s) => s[0]).join('').toUpperCase().slice(0, 2)
|
||||
: '?';
|
||||
? displayName
|
||||
.split(/\s+/)
|
||||
.map((s) => s[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2)
|
||||
: "?";
|
||||
|
||||
const handleLogout = useCallback(() => {
|
||||
dispatch(logout());
|
||||
navigate('/login');
|
||||
navigate("/login");
|
||||
}, [dispatch, navigate]);
|
||||
|
||||
const segments = location.pathname.split('/').filter(Boolean);
|
||||
const segments = location.pathname.split("/").filter(Boolean);
|
||||
const crumbs = [
|
||||
{ label: t('nav.dashboard'), path: '/dashboard' },
|
||||
{ label: t("nav.dashboard"), path: "/dashboard" },
|
||||
...segments
|
||||
.map((_, i) => '/' + segments.slice(0, i + 1).join('/'))
|
||||
.filter((path) => path !== '/dashboard')
|
||||
.filter((path) => !path.startsWith('/um'))
|
||||
.map((path) => ({ label: t('nav.dashboard'), path })),
|
||||
.map((_, i) => "/" + segments.slice(0, i + 1).join("/"))
|
||||
.filter((path) => path !== "/dashboard")
|
||||
.filter((path) => !path.startsWith("/um"))
|
||||
.map((path) => ({ label: t("nav.dashboard"), path })),
|
||||
];
|
||||
|
||||
const go = (item: NavItem) => {
|
||||
@@ -115,27 +175,31 @@ export function BackofficeLayout() {
|
||||
setCollapsed((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const isSidebar = layoutMode === 'sidebar';
|
||||
const isSidebar = layoutMode === "sidebar";
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
header={{ height: isSidebar ? 74 : HEADER_HEIGHT }}
|
||||
navbar={isSidebar ? {
|
||||
width: collapsed ? 72 : 264,
|
||||
breakpoint: 'sm',
|
||||
collapsed: { mobile: !opened },
|
||||
} : undefined}
|
||||
navbar={
|
||||
isSidebar
|
||||
? {
|
||||
width: collapsed ? 72 : 264,
|
||||
breakpoint: "sm",
|
||||
collapsed: { mobile: !opened },
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
padding="lg"
|
||||
>
|
||||
<AppShell.Header
|
||||
style={{
|
||||
background: 'var(--mantine-color-body)',
|
||||
borderBottom: '1px solid var(--mantine-color-gray-2)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: "var(--mantine-color-body)",
|
||||
borderBottom: "1px solid var(--mantine-color-gray-2)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<div style={{ height: 74, flexShrink: 0, padding: '0 32px' }}>
|
||||
<div style={{ height: 74, flexShrink: 0, padding: "0 32px" }}>
|
||||
<AppHeader
|
||||
onToggleNav={toggleNav}
|
||||
onToggleSidebar={isSidebar ? handleToggleCollapse : toggleNav}
|
||||
@@ -143,7 +207,7 @@ export function BackofficeLayout() {
|
||||
breadcrumbs={crumbs}
|
||||
onNavigate={navigate}
|
||||
onLogout={handleLogout}
|
||||
userName={displayName || t('app.name')}
|
||||
userName={displayName || t("app.name")}
|
||||
userInitials={initials}
|
||||
supportedLanguages={SUPPORTED_LANGUAGES}
|
||||
/>
|
||||
@@ -152,51 +216,58 @@ export function BackofficeLayout() {
|
||||
{!isSidebar && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: rem(2),
|
||||
padding: '0 32px',
|
||||
padding: "0 32px",
|
||||
height: 42,
|
||||
borderTop: '1px solid var(--mantine-color-gray-1)',
|
||||
overflowX: 'auto',
|
||||
borderTop: "1px solid var(--mantine-color-gray-1)",
|
||||
overflowX: "auto",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const active = !!item.to && (location.pathname === item.to || location.pathname.startsWith(`${item.to}/`));
|
||||
const active =
|
||||
!!item.to &&
|
||||
(location.pathname === item.to ||
|
||||
location.pathname.startsWith(`${item.to}/`));
|
||||
const ItemIcon = item.icon;
|
||||
return (
|
||||
<button
|
||||
key={item.to}
|
||||
onClick={() => go(item)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: rem(6),
|
||||
padding: '8px 16px',
|
||||
border: 'none',
|
||||
borderBottom: '2px solid',
|
||||
padding: "8px 16px",
|
||||
border: "none",
|
||||
borderBottom: "2px solid",
|
||||
borderBottomColor: active
|
||||
? 'var(--mantine-color-blue-6)'
|
||||
: 'transparent',
|
||||
background: 'transparent',
|
||||
? "var(--mantine-color-blue-6)"
|
||||
: "transparent",
|
||||
background: "transparent",
|
||||
color: active
|
||||
? 'var(--mantine-color-blue-6)'
|
||||
: 'var(--mantine-color-gray-6)',
|
||||
? "var(--mantine-color-blue-6)"
|
||||
: "var(--mantine-color-gray-6)",
|
||||
fontWeight: active ? 600 : 500,
|
||||
fontSize: rem(14),
|
||||
whiteSpace: 'nowrap',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 150ms ease',
|
||||
height: '100%',
|
||||
whiteSpace: "nowrap",
|
||||
cursor: "pointer",
|
||||
transition: "all 150ms ease",
|
||||
height: "100%",
|
||||
marginBottom: -1,
|
||||
fontFamily: 'inherit',
|
||||
fontFamily: "inherit",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!active) e.currentTarget.style.color = 'var(--mantine-color-blue-6)';
|
||||
if (!active)
|
||||
e.currentTarget.style.color =
|
||||
"var(--mantine-color-blue-6)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!active) e.currentTarget.style.color = 'var(--mantine-color-gray-6)';
|
||||
if (!active)
|
||||
e.currentTarget.style.color =
|
||||
"var(--mantine-color-gray-6)";
|
||||
}}
|
||||
>
|
||||
<ItemIcon size={18} stroke={1.6} />
|
||||
@@ -212,10 +283,10 @@ export function BackofficeLayout() {
|
||||
<AppShell.Navbar
|
||||
p={0}
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
transition: 'width 200ms ease',
|
||||
background: 'var(--mantine-color-body)',
|
||||
borderRight: '1px solid var(--mantine-color-gray-2)',
|
||||
overflow: "hidden",
|
||||
transition: "width 200ms ease",
|
||||
background: "var(--mantine-color-body)",
|
||||
borderRight: "1px solid var(--mantine-color-gray-2)",
|
||||
}}
|
||||
>
|
||||
<AppSidebar
|
||||
@@ -224,8 +295,8 @@ export function BackofficeLayout() {
|
||||
activePath={location.pathname}
|
||||
onToggleCollapse={handleToggleCollapse}
|
||||
onNavigate={go}
|
||||
brandName={t('app.name')}
|
||||
brandSubtitle={t('app.authority')}
|
||||
brandName={t("app.name")}
|
||||
brandSubtitle={t("app.authority")}
|
||||
/>
|
||||
</AppShell.Navbar>
|
||||
)}
|
||||
|
||||
@@ -30,9 +30,6 @@ import { QuestionPage } from '../features/question/pages/QuestionPage';
|
||||
import { ExamPage } from '../features/exam/pages/ExamPage';
|
||||
import { ExamDetailPage } from '../features/exam/pages/ExamDetailPage';
|
||||
import { ResultPage } from '../features/result/pages/ResultPage';
|
||||
import { VesselRegistrationQueuePage } from '../features/vessel-registration/pages/VesselRegistrationQueuePage';
|
||||
import { VesselRegistrationReviewPage } from '../features/vessel-registration/pages/VesselRegistrationReviewPage';
|
||||
import { VesselRegistrationReportPage } from '../features/vessel-registration/pages/VesselRegistrationReportPage';
|
||||
import { VesselRegistrationFormBuilderPage } from '../features/vessel-registration/pages/VesselRegistrationFormBuilderPage';
|
||||
import { VesselOwnershipTransferQueuePage } from '../features/vessel-registration/pages/VesselOwnershipTransferQueuePage';
|
||||
import { VesselOwnershipTransferReviewPage } from '../features/vessel-registration/pages/VesselOwnershipTransferReviewPage';
|
||||
@@ -50,6 +47,11 @@ import { MtoLicenseReviewPage } from '../features/mto-license/pages/MtoLicenseRe
|
||||
import { WaiverQueuePage } from '../features/waiver/pages/WaiverQueuePage';
|
||||
import { WaiverReviewPage } from '../features/waiver/pages/WaiverReviewPage';
|
||||
import { LogisticsHeadDashboardPage } from '../features/logistics-head/pages/LogisticsHeadDashboardPage';
|
||||
import { VesselTransferQueuePage } from '../features/vessel-transfer/pages/VesselTransferQueuePage';
|
||||
import { VesselTransferReviewPage } from '../features/vessel-transfer/pages/VesselTransferReviewPage';
|
||||
import { VesselRegistrationQueuePage } from '../features/vessel-registration/pages/VesselRegistrationQueuePage';
|
||||
import { VesselRegistrationReviewPage } from '../features/vessel-registration/pages/VesselRegistrationReviewPage';
|
||||
import { VesselRegistrationReportPage } from '../features/vessel-registration/pages/VesselRegistrationReportPage';
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
@@ -86,9 +88,6 @@ const router = createBrowserRouter([
|
||||
{ path: 'payment-config', element: <PaymentConfigPage /> },
|
||||
{ path: 'seafarer-registry', element: <SeafarerRegistryPage /> },
|
||||
{ path: 'seaman-book-queue', element: <SeamanBookQueuePage /> },
|
||||
{ path: 'vessel-registrations', element: <VesselRegistrationQueuePage /> },
|
||||
{ path: 'vessel-registrations/:id', element: <VesselRegistrationReviewPage /> },
|
||||
{ path: 'vessel-registration-report', element: <VesselRegistrationReportPage /> },
|
||||
{ path: 'vessel-transfers', element: <VesselTransferQueuePage /> },
|
||||
{ path: 'vessel-transfers/:id', element: <VesselTransferReviewPage /> },
|
||||
{ path: 'questions', element: <QuestionPage /> },
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Divider,
|
||||
Group,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Stepper,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconArrowLeft,
|
||||
IconCircleCheck,
|
||||
IconClock,
|
||||
IconDownload,
|
||||
IconInfoCircle,
|
||||
IconShip,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { MOCK_REGISTRATIONS, recordDownload, STATUS_COLOR } from '../mock';
|
||||
|
||||
// ponytail: placeholder PDF blob; wire real cert endpoint when backend lands.
|
||||
const BLANK_PDF = 'data:application/pdf;base64,JVBERi0xLjQKJcfsj6IKMSAwIG9iago8PC9UeXBlL0NhdGFsb2cvUGFnZXMgMiAwIFI+PgplbmRvYmoKMiAwIG9iago8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PgplbmRvYmoKMyAwIG9iago8PC9UeXBlL1BhZ2UvUGFyZW50IDIgMCBSL01lZGlhQm94WzAgMCA2MTIgNzkyXT4+CmVuZG9iagp4cmVmCjAgNAowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMDkgMDAwMDAgbiAKMDAwMDAwMDA1OCAwMDAwMCBuIAowMDAwMDAwMTE1IDAwMDAwIG4gCnRyYWlsZXIKPDwvU2l6ZSA0L1Jvb3QgMSAwIFI+PgpzdGFydHhyZWYKMjE3CiUlRU9G';
|
||||
|
||||
function downloadCertificate(filename: string) {
|
||||
const a = document.createElement('a');
|
||||
a.href = BLANK_PDF;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
}
|
||||
|
||||
export function VesselRegistrationStatusPage() {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams();
|
||||
const [reg] = useState(() => MOCK_REGISTRATIONS.find((r) => r.id === id) ?? null);
|
||||
const [, forceUpdate] = useState(0);
|
||||
|
||||
if (!reg) {
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Button variant="subtle" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/vessel-registrations')}>Back</Button>
|
||||
<Alert variant="light" color="red" icon={<IconInfoCircle size={15} />}>Registration not found.</Alert>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const activeStep = reg.timeline.filter((t) => t.done).length - 1;
|
||||
const needsCorrection = reg.status === 'Correction Required' || reg.status === 'Rejected';
|
||||
|
||||
const handleDownload = (certName: string, certNumber: string) => {
|
||||
downloadCertificate(`${certName.replace(/\s+/g, '-')}-${certNumber}.pdf`);
|
||||
recordDownload(reg.id, certName);
|
||||
forceUpdate((n) => n + 1);
|
||||
notify.success(`${certName} downloaded.`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group gap="sm">
|
||||
<Button variant="subtle" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/vessel-registrations')}>Back</Button>
|
||||
<div>
|
||||
<Title order={3}>{reg.vesselName}</Title>
|
||||
<Text fz="sm" c="dimmed">{reg.id} — {reg.category}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Group justify="space-between" mb="md" wrap="wrap" gap="sm">
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size={36} radius="md" color="blue" variant="light"><IconShip size={18} /></ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} fz="sm">Registration Status</Text>
|
||||
<Text fz="xs" c="dimmed">Submitted {reg.submitted}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Badge color={STATUS_COLOR[reg.status]} variant="light" size="lg">{reg.status}</Badge>
|
||||
</Group>
|
||||
|
||||
{reg.renewal && reg.renewal !== 'OK' && (
|
||||
<Alert
|
||||
variant="light"
|
||||
color={reg.renewal === 'Overdue' ? 'red' : 'orange'}
|
||||
icon={<IconAlertTriangle size={15} />}
|
||||
mb="md"
|
||||
p="sm"
|
||||
>
|
||||
<Text fz="sm">
|
||||
Registration {reg.renewal === 'Overdue' ? 'is overdue for renewal' : 'is due for renewal soon'}
|
||||
{reg.expiryDate ? ` — expires ${reg.expiryDate}` : ''}.
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{reg.remarks && (
|
||||
<Alert variant="light" color={needsCorrection ? 'orange' : 'blue'} icon={<IconInfoCircle size={15} />} mb="md" p="sm">
|
||||
<Text fz="sm" fw={600} mb={2}>Officer Remarks</Text>
|
||||
<Text fz="sm">{reg.remarks}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Stepper active={activeStep} size="sm" color="teal">
|
||||
{reg.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>
|
||||
|
||||
{needsCorrection && (
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button color="orange" onClick={() => navigate('/vessel-registrations/apply')}>Resubmit Application</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{reg.status === 'Approved' && reg.certificates && (
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Text fw={700} mb="md">Certificates</Text>
|
||||
<Stack gap="sm">
|
||||
{reg.certificates.map((cert) => (
|
||||
<div key={cert.name}>
|
||||
<Group justify="space-between" wrap="wrap" gap="sm">
|
||||
<div>
|
||||
<Text fw={600} fz="sm">{cert.name}</Text>
|
||||
<Text fz="xs" c="dimmed">Certificate No. {cert.number} — Issued {cert.issueDate}</Text>
|
||||
{cert.downloads > 0 && (
|
||||
<Text fz="xs" c="dimmed">Downloaded {cert.downloads} time{cert.downloads === 1 ? '' : 's'}</Text>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="xs"
|
||||
leftSection={<IconDownload size={14} />}
|
||||
onClick={() => handleDownload(cert.name, cert.number)}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</Group>
|
||||
<Divider mt="sm" />
|
||||
</div>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
// ponytail: in-memory mock array, resets on reload — swap for RTK Query endpoint when backend lands.
|
||||
|
||||
import type { UploadFileInfo } from '@ema-platform/api';
|
||||
|
||||
export interface Vessel {
|
||||
id: string;
|
||||
name: string;
|
||||
regNo: string;
|
||||
approved: boolean;
|
||||
}
|
||||
|
||||
export const MOCK_VESSELS: Vessel[] = [
|
||||
{ id: 'v1', name: 'MV Abay', regNo: 'ET-VSL-2021-014', approved: true },
|
||||
{ id: 'v2', name: 'MV Tana', regNo: 'ET-VSL-2022-031', approved: true },
|
||||
{ id: 'v3', name: 'MV Awash', regNo: 'ET-VSL-2023-009', approved: false }, // pending registration — not selectable
|
||||
];
|
||||
|
||||
export const TRANSFER_REASONS = [
|
||||
'Sale',
|
||||
'Inheritance',
|
||||
'Gift',
|
||||
'Corporate Restructuring',
|
||||
'Court Order',
|
||||
'Other',
|
||||
] as const;
|
||||
|
||||
export type TransferReason = (typeof TRANSFER_REASONS)[number];
|
||||
|
||||
export type TransferStatus = 'Pending' | 'Under Review' | 'Approved' | 'Rejected';
|
||||
|
||||
export const STATUS_COLOR: Record<TransferStatus, string> = {
|
||||
Pending: 'gray',
|
||||
'Under Review': 'blue',
|
||||
Approved: 'teal',
|
||||
Rejected: 'red',
|
||||
};
|
||||
|
||||
export interface TransferRequest {
|
||||
id: string;
|
||||
vesselName: string;
|
||||
vesselRegNo: string;
|
||||
currentOwner: string;
|
||||
newOwnerName: string;
|
||||
reason: TransferReason;
|
||||
status: TransferStatus;
|
||||
submitted: string;
|
||||
approvedOn?: string;
|
||||
rejectionReason?: string;
|
||||
documentFileInfo?: UploadFileInfo;
|
||||
}
|
||||
|
||||
export const MOCK_REQUESTS: TransferRequest[] = [
|
||||
{
|
||||
id: 'VT-2025-0001',
|
||||
vesselName: 'MV Abay',
|
||||
vesselRegNo: 'ET-VSL-2021-014',
|
||||
currentOwner: 'Solomon Bekele',
|
||||
newOwnerName: 'Bahir Dar Shipping PLC',
|
||||
reason: 'Corporate Restructuring',
|
||||
status: 'Under Review',
|
||||
submitted: '2025-06-30',
|
||||
},
|
||||
{
|
||||
id: 'VT-2025-0002',
|
||||
vesselName: 'MV Tana',
|
||||
vesselRegNo: 'ET-VSL-2022-031',
|
||||
currentOwner: 'Solomon Bekele',
|
||||
newOwnerName: 'Hana Girma',
|
||||
reason: 'Sale',
|
||||
status: 'Approved',
|
||||
submitted: '2025-05-12',
|
||||
approvedOn: '2025-05-28',
|
||||
},
|
||||
{
|
||||
id: 'VT-2025-0003',
|
||||
vesselName: 'MV Abay',
|
||||
vesselRegNo: 'ET-VSL-2021-014',
|
||||
currentOwner: 'Solomon Bekele',
|
||||
newOwnerName: 'Dawit Alemu',
|
||||
reason: 'Gift',
|
||||
status: 'Rejected',
|
||||
submitted: '2025-04-02',
|
||||
rejectionReason: 'Bill of Sale document illegible — please resubmit a clearer scan.',
|
||||
},
|
||||
];
|
||||
|
||||
export function addTransferRequest(req: Omit<TransferRequest, 'id' | 'status' | 'submitted'>): TransferRequest {
|
||||
const created: TransferRequest = {
|
||||
...req,
|
||||
id: `VT-2025-${String(MOCK_REQUESTS.length + 1).padStart(4, '0')}`,
|
||||
status: 'Pending',
|
||||
submitted: new Date().toISOString().slice(0, 10),
|
||||
};
|
||||
MOCK_REQUESTS.unshift(created);
|
||||
return created;
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Divider,
|
||||
FileInput,
|
||||
Group,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconArrowLeft, IconArrowRight, IconCheck, IconInfoCircle, IconUpload } from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { isStorageUploadError, useDocumentUpload } from '@ema-platform/api';
|
||||
import { addTransferRequest, MOCK_VESSELS, TRANSFER_REASONS } from '../mock';
|
||||
import { useAppSelector } from '../../../store/hooks';
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
export function VesselTransferApplicationPage() {
|
||||
const navigate = useNavigate();
|
||||
const user = useAppSelector((state) => state.auth.user);
|
||||
const currentOwner = user?.name?.en || user?.username || 'Current Owner';
|
||||
|
||||
const [step, setStep] = useState(0);
|
||||
|
||||
// Step 0
|
||||
const [vesselId, setVesselId] = useState<string | null>(null);
|
||||
const vessel = MOCK_VESSELS.find((v) => v.id === vesselId) ?? null;
|
||||
|
||||
// Step 1 — new owner
|
||||
const [newOwnerName, setNewOwnerName] = useState('');
|
||||
const [newOwnerIdOrTin, setNewOwnerIdOrTin] = useState('');
|
||||
const [newOwnerPhone, setNewOwnerPhone] = useState('');
|
||||
const [newOwnerEmail, setNewOwnerEmail] = useState('');
|
||||
const [newOwnerAddress, setNewOwnerAddress] = useState('');
|
||||
|
||||
// Step 2 — reason + document
|
||||
const [reason, setReason] = useState<string | null>(null);
|
||||
const [document, setDocument] = useState<File | null>(null);
|
||||
|
||||
const { upload, isUploading } = useDocumentUpload();
|
||||
|
||||
const step0Ok = !!vessel;
|
||||
const step1Ok = !!newOwnerName && !!newOwnerIdOrTin && !!newOwnerPhone
|
||||
&& EMAIL_RE.test(newOwnerEmail) && !!newOwnerAddress;
|
||||
const step2Ok = !!reason && !!document;
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!vessel || !reason || !document) return;
|
||||
try {
|
||||
const { fileInfo } = await upload(document, '/documents/get-file-upload-key');
|
||||
addTransferRequest({
|
||||
vesselName: vessel.name,
|
||||
vesselRegNo: vessel.regNo,
|
||||
currentOwner,
|
||||
newOwnerName,
|
||||
reason: reason as (typeof TRANSFER_REASONS)[number],
|
||||
documentFileInfo: fileInfo,
|
||||
});
|
||||
notify.success('Transfer request submitted. SMS and email confirmation sent.');
|
||||
navigate('/vessel-transfers');
|
||||
} catch (error) {
|
||||
const tooLarge = isStorageUploadError(error) && error.message === 'STORAGE_UPLOAD_TOO_LARGE';
|
||||
notify.error(tooLarge ? 'File is too large to upload.' : 'Failed to upload document. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group gap="sm">
|
||||
<Button variant="subtle" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/vessel-transfers')}>Back</Button>
|
||||
<div>
|
||||
<Title order={3}>Request Ownership Transfer</Title>
|
||||
<Text fz="sm" c="dimmed">Submit a request to transfer your vessel to a new owner.</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
{/* Step 0 — Select vessel */}
|
||||
{step === 0 && (
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Stack gap="md">
|
||||
<Text fw={700}>Select Vessel</Text>
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={15} />}>
|
||||
<Text fz="sm">Only approved vessels are available for transfer.</Text>
|
||||
</Alert>
|
||||
<Select
|
||||
label="Vessel"
|
||||
placeholder="Select a vessel…"
|
||||
data={MOCK_VESSELS.filter((v) => v.approved).map((v) => ({ value: v.id, label: `${v.name} — ${v.regNo}` }))}
|
||||
value={vesselId}
|
||||
onChange={setVesselId}
|
||||
required
|
||||
/>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Step 1 — New owner details */}
|
||||
{step === 1 && (
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Stack gap="md">
|
||||
<Text fw={700}>New Owner Details</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<TextInput label="Full Name / Company Name" value={newOwnerName} onChange={(e) => setNewOwnerName(e.currentTarget.value)} required />
|
||||
<TextInput label="National ID / TIN" value={newOwnerIdOrTin} onChange={(e) => setNewOwnerIdOrTin(e.currentTarget.value)} required />
|
||||
<TextInput label="Phone Number" value={newOwnerPhone} onChange={(e) => setNewOwnerPhone(e.currentTarget.value)} required />
|
||||
<TextInput
|
||||
label="Email Address"
|
||||
value={newOwnerEmail}
|
||||
onChange={(e) => setNewOwnerEmail(e.currentTarget.value)}
|
||||
error={newOwnerEmail && !EMAIL_RE.test(newOwnerEmail) ? 'Enter a valid email' : undefined}
|
||||
required
|
||||
/>
|
||||
</SimpleGrid>
|
||||
<Textarea label="Address" value={newOwnerAddress} onChange={(e) => setNewOwnerAddress(e.currentTarget.value)} required />
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Step 2 — Reason + document */}
|
||||
{step === 2 && (
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Stack gap="md">
|
||||
<Text fw={700}>Transfer Reason & Document</Text>
|
||||
<Select
|
||||
label="Transfer Reason"
|
||||
placeholder="Select a reason…"
|
||||
data={[...TRANSFER_REASONS]}
|
||||
value={reason}
|
||||
onChange={setReason}
|
||||
required
|
||||
/>
|
||||
<FileInput
|
||||
label={<Group gap={4}><Text fz="sm" fw={500}>Bill of Sale / Transfer Document</Text><Badge size="xs" color="red" variant="light">Required</Badge></Group>}
|
||||
placeholder="Click to upload"
|
||||
leftSection={<IconUpload size={14} />}
|
||||
value={document}
|
||||
onChange={setDocument}
|
||||
accept=".pdf,.jpg,.jpeg,.png"
|
||||
clearable
|
||||
/>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Step 3 — Review */}
|
||||
{step === 3 && vessel && (
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Stack gap="lg">
|
||||
<Text fw={700}>Review & Submit</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
{[
|
||||
['Vessel', `${vessel.name} — ${vessel.regNo}`],
|
||||
['Current Owner', currentOwner],
|
||||
['New Owner', newOwnerName],
|
||||
['National ID / TIN', newOwnerIdOrTin],
|
||||
['Phone', newOwnerPhone],
|
||||
['Email', newOwnerEmail],
|
||||
['Address', newOwnerAddress],
|
||||
['Transfer Reason', reason ?? ''],
|
||||
].map(([label, value]) => (
|
||||
<div key={label}>
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>{label}</Text>
|
||||
<Text fz="sm" fw={500}>{value || '—'}</Text>
|
||||
</div>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
<Divider />
|
||||
<Group gap="xs">
|
||||
<Text fz="xs" fw={700} tt="uppercase" c="dimmed">Document:</Text>
|
||||
<Text fz="sm" c="blue.7">{document?.name}</Text>
|
||||
</Group>
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={14} />}>
|
||||
<Text fz="xs">By submitting you confirm the new owner details are accurate and the uploaded document is genuine.</Text>
|
||||
</Alert>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Navigation */}
|
||||
<Group justify="space-between" mt="md">
|
||||
<Button variant="default" leftSection={<IconArrowLeft size={14} />} onClick={() => setStep((s) => s - 1)} disabled={step === 0}>
|
||||
Back
|
||||
</Button>
|
||||
{step < 3 ? (
|
||||
<Button
|
||||
rightSection={<IconArrowRight size={14} />}
|
||||
disabled={(step === 0 && !step0Ok) || (step === 1 && !step1Ok) || (step === 2 && !step2Ok)}
|
||||
onClick={() => setStep((s) => s + 1)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
) : (
|
||||
<Button color="teal" leftSection={<IconCheck size={14} />} onClick={handleSubmit} loading={isUploading}>
|
||||
Submit Request
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Group,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconAnchor, IconArrowRight, IconInfoCircle, IconShip } from '@tabler/icons-react';
|
||||
import { MOCK_REQUESTS, STATUS_COLOR } from '../mock';
|
||||
|
||||
export function VesselTransferPage() {
|
||||
const navigate = useNavigate();
|
||||
// MOCK_REQUESTS is mutated in place by addTransferRequest — copy so React notices the change on navigation back.
|
||||
const [requests] = useState(() => [...MOCK_REQUESTS]);
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="sm">
|
||||
<div>
|
||||
<Title order={3}>Vessel Ownership Transfers</Title>
|
||||
<Text fz="sm" c="dimmed">Request and track the transfer of a vessel's registered ownership.</Text>
|
||||
</div>
|
||||
<Button leftSection={<IconShip size={15} />} rightSection={<IconArrowRight size={15} />} onClick={() => navigate('/vessel-transfers/apply')}>
|
||||
Request Ownership Transfer
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Text fw={700} mb="md">My Transfer Requests</Text>
|
||||
{requests.length === 0 ? (
|
||||
<Alert variant="light" color="gray" icon={<IconInfoCircle size={15} />}>
|
||||
No transfer requests yet.
|
||||
</Alert>
|
||||
) : (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
{requests.map((req) => (
|
||||
<Card key={req.id} withBorder radius="md" p="md">
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size={36} radius="md" color="blue" variant="light"><IconAnchor size={18} /></ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} fz="sm">{req.vesselName}</Text>
|
||||
<Text fz="xs" c="dimmed">{req.vesselRegNo}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Badge color={STATUS_COLOR[req.status]} variant="light">{req.status}</Badge>
|
||||
</Group>
|
||||
<Divider mb="sm" />
|
||||
<SimpleGrid cols={2} spacing="xs" mb="sm">
|
||||
<div><Text fz="xs" c="dimmed">Current Owner</Text><Text fz="sm" fw={500}>{req.currentOwner}</Text></div>
|
||||
<div><Text fz="xs" c="dimmed">New Owner</Text><Text fz="sm" fw={500}>{req.newOwnerName}</Text></div>
|
||||
<div><Text fz="xs" c="dimmed">Reason</Text><Text fz="sm" fw={500}>{req.reason}</Text></div>
|
||||
<div><Text fz="xs" c="dimmed">Submitted</Text><Text fz="sm" fw={500}>{req.submitted}</Text></div>
|
||||
</SimpleGrid>
|
||||
{req.status === 'Approved' && (
|
||||
<Alert variant="light" color="teal" icon={<IconInfoCircle size={13} />} p="xs">
|
||||
<Text fz="xs">Transfer approved on {req.approvedOn}. New certificates issued to {req.newOwnerName}.</Text>
|
||||
</Alert>
|
||||
)}
|
||||
{req.status === 'Rejected' && (
|
||||
<Alert variant="light" color="red" icon={<IconInfoCircle size={13} />} p="xs">
|
||||
<Text fz="xs">{req.rejectionReason}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user