mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
Merge branch 'WorkflowChange' of github.com:Tria-plc/emaui into WorkflowChange
This commit is contained in:
@@ -6,9 +6,122 @@
|
||||
<link rel="icon" type="image/png" href="/ema-logo.png" />
|
||||
<link rel="apple-touch-icon" href="/ema-logo.png" />
|
||||
<title>EMA Backoffice</title>
|
||||
<script>
|
||||
// Apply the saved Mantine color scheme before paint to avoid a flash.
|
||||
try {
|
||||
var s = localStorage.getItem('mantine-color-scheme-value') || 'light';
|
||||
document.documentElement.setAttribute('data-mantine-color-scheme', s);
|
||||
} catch (e) {}
|
||||
</script>
|
||||
<style>
|
||||
/* Boot splash — shown until React mounts into #root. Colors are
|
||||
hardcoded (Mantine's default light/dark-7 body background) so the
|
||||
splash never depends on the app's own stylesheet finishing its load. */
|
||||
#ema-boot-splash {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #0f172a;
|
||||
color: #38bdf8;
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
html[data-mantine-color-scheme='light'] #ema-boot-splash {
|
||||
background: #f8fafc;
|
||||
color: #0f2c59;
|
||||
}
|
||||
#ema-boot-splash .ema-card {
|
||||
padding: 2.5rem 3.5rem;
|
||||
border-radius: 1.5rem;
|
||||
background: rgba(15, 23, 42, 0.85);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.35);
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
html[data-mantine-color-scheme='light'] #ema-boot-splash .ema-card {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border: 1px solid rgba(15, 44, 89, 0.1);
|
||||
box-shadow: 0 25px 50px -12px rgba(11, 25, 44, 0.12);
|
||||
}
|
||||
#ema-boot-splash svg {
|
||||
width: 140px;
|
||||
height: auto;
|
||||
}
|
||||
.ema-boot-compass {
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
animation: ema-boot-spin 20s linear infinite;
|
||||
}
|
||||
.ema-boot-helm {
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
animation: ema-boot-spin-rev 14s linear infinite;
|
||||
}
|
||||
.ema-boot-title {
|
||||
margin-top: 1rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
background: linear-gradient(135deg, #078930, #fcd116, #2563eb);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
.ema-boot-sub {
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
opacity: 0.85;
|
||||
}
|
||||
@keyframes ema-boot-spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
@keyframes ema-boot-spin-rev {
|
||||
from { transform: rotate(360deg); }
|
||||
to { transform: rotate(0deg); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ema-boot-compass, .ema-boot-helm { animation: none; }
|
||||
}
|
||||
/* Hide splash once React mounts */
|
||||
#root:not(:empty) ~ #ema-boot-splash {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<div id="ema-boot-splash" role="status" aria-live="polite" aria-label="Loading Ethiopian Maritime Backoffice">
|
||||
<div class="ema-card">
|
||||
<div style="position: relative; width: 120px; height: 120px; display: flex; align-items: center; justify-content: center;">
|
||||
<svg viewBox="0 0 120 120" style="position: absolute; inset: 0; width: 100%; height: 100%;">
|
||||
<defs>
|
||||
<linearGradient id="bo-ring-1" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#0284C7" />
|
||||
<stop offset="100%" stop-color="#078930" />
|
||||
</linearGradient>
|
||||
<linearGradient id="bo-ring-2" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#F59E0B" />
|
||||
<stop offset="100%" stop-color="#FCD116" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="60" cy="60" r="54" fill="none" stroke="url(#bo-ring-1)" stroke-width="1.8" stroke-dasharray="8 6 2 6" opacity="0.85" class="ema-boot-compass" />
|
||||
<circle cx="60" cy="60" r="39" fill="none" stroke="url(#bo-ring-2)" stroke-width="2" stroke-dasharray="28 14" class="ema-boot-helm" />
|
||||
</svg>
|
||||
<img src="/ema-logo.png" alt="EMA" style="width: 58px; height: 58px; object-fit: contain; position: relative; z-index: 2;" />
|
||||
</div>
|
||||
<div class="ema-boot-title">ETHIOPIAN MARITIME AUTHORITY</div>
|
||||
<div style="font-size: 0.7rem; opacity: 0.6; margin-top: 2px;">የኢትዮጵያ ማሪታይም ባለስልጣን</div>
|
||||
<div class="ema-boot-sub">Loading Maritime Backoffice…</div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { extractErrorMessage } from '@ema-platform/api';
|
||||
@@ -13,12 +13,14 @@ interface PreviewArgs {
|
||||
|
||||
/**
|
||||
* Renders the editor's current contents, not the saved row, so unsaved edits
|
||||
* are what you see. Opened as a blob so it never leaves a file behind.
|
||||
* are what you see. Opened as a blob into `PdfPreviewModal` rather than a new
|
||||
* tab, so the designer never loses their place.
|
||||
*/
|
||||
export function useTemplatePreview() {
|
||||
const { t } = useTranslation();
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
|
||||
return useCallback(
|
||||
const open = useCallback(
|
||||
async ({ hbsSource, licenseTypeId, landscape }: PreviewArgs) => {
|
||||
try {
|
||||
// The preview returns a PDF stream, not JSON, so it bypasses RTK Query
|
||||
@@ -38,10 +40,7 @@ export function useTemplatePreview() {
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
const url = URL.createObjectURL(await response.blob());
|
||||
window.open(url, '_blank', 'noopener');
|
||||
// Give the new tab time to read it before revoking.
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
setPreviewUrl(URL.createObjectURL(await response.blob()));
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
@@ -52,4 +51,13 @@ export function useTemplatePreview() {
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const close = useCallback(() => {
|
||||
setPreviewUrl((current) => {
|
||||
if (current) URL.revokeObjectURL(current);
|
||||
return null;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return { previewUrl, open, close };
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
useUpdateLicenseValidityMutation,
|
||||
useUpdateLicenseTemplateMutation,
|
||||
} from '@ema-platform/api';
|
||||
import { EmptyState, ErrorState, PageHeader } from '@ema-platform/ui';
|
||||
import { EmptyState, ErrorState, PageHeader, PdfPreviewModal } from '@ema-platform/ui';
|
||||
import { usePermissions, LICENSE_PERMISSIONS as PERMISSIONS } from '@ema-platform/auth';
|
||||
import { BlockPropertiesPanel } from '../components/BlockPropertiesPanel';
|
||||
import { DesignerToolbar } from '../components/DesignerToolbar';
|
||||
@@ -86,7 +86,7 @@ export function CertificateDesignerPage() {
|
||||
|
||||
const draft = useTemplateDraft(templates);
|
||||
const run = useDesignerActions();
|
||||
const openPreview = useTemplatePreview();
|
||||
const { previewUrl, open: openPreview, close: closePreview } = useTemplatePreview();
|
||||
|
||||
const [newOpen, setNewOpen] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
@@ -377,6 +377,13 @@ export function CertificateDesignerPage() {
|
||||
}, t('designer.created', 'Draft created'))
|
||||
}
|
||||
/>
|
||||
|
||||
<PdfPreviewModal
|
||||
opened={Boolean(previewUrl)}
|
||||
onClose={closePreview}
|
||||
url={previewUrl ?? ''}
|
||||
title={t('designer.preview', 'Preview')}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
AdvancedTable,
|
||||
useServerTable,
|
||||
ModalFooter,
|
||||
PageLoader,
|
||||
} from "@ema-platform/ui";
|
||||
import { LocationPage } from "../../../location/pages/LocationPage";
|
||||
import { CertificationPage } from "../../../certification/pages/CertificationPage";
|
||||
@@ -294,11 +295,7 @@ function ProfessionTab() {
|
||||
];
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center py="xl">
|
||||
<Loader />
|
||||
</Center>
|
||||
);
|
||||
return <PageLoader label="Loading Configuration…" height={400} />;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from '@mantine/core';
|
||||
import { IconChevronRight } from '@tabler/icons-react';
|
||||
import { useGetAssignedToMeQuery, useGetQueueQuery } from '@ema-platform/api';
|
||||
import { AdvancedTable, useServerTable } from '@ema-platform/ui';
|
||||
import { AdvancedTable, PageLoader, useServerTable } from '@ema-platform/ui';
|
||||
import { dashboardQueueColumns } from './columns';
|
||||
|
||||
/**
|
||||
@@ -28,11 +28,7 @@ export function DashboardPage() {
|
||||
const table = useServerTable();
|
||||
|
||||
if (queue.isLoading || mine.isLoading) {
|
||||
return (
|
||||
<Center h={300}>
|
||||
<Loader />
|
||||
</Center>
|
||||
);
|
||||
return <PageLoader label="Loading Backoffice Dashboard…" height={400} />;
|
||||
}
|
||||
|
||||
const unclaimed = queue.data?.items ?? [];
|
||||
|
||||
@@ -54,6 +54,7 @@ import { QuestionAssigner } from '../components/QuestionAssigner';
|
||||
import { RecordResultModal } from '../../result/components/RecordResultModal';
|
||||
import { ExamCandidatesPanel } from '../components/ExamCandidatesPanel';
|
||||
import { ExamIncidentsPanel } from '../components/ExamIncidentsPanel';
|
||||
import { PageLoader } from '@ema-platform/ui';
|
||||
import type { ExamStatus, QuestionBrief } from '../types/exam';
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
@@ -129,11 +130,7 @@ export function ExamDetailPage() {
|
||||
}, [allQuestions, exam?.certificationId, exam?.form]);
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<Center py="xl">
|
||||
<Loader />
|
||||
</Center>
|
||||
);
|
||||
return <PageLoader label="Loading Exam Details…" height={400} />;
|
||||
if (isError || !exam) {
|
||||
return (
|
||||
<Stack gap="md">
|
||||
|
||||
@@ -76,9 +76,13 @@ export function DecisionBar({
|
||||
role="region"
|
||||
aria-label={t('review.decisionBar', 'Decision bar')}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap" gap="md">
|
||||
{/* Wraps rather than overflows: at narrow widths the nowrap row pushed
|
||||
the workflow buttons past the viewport edge, so Assign, Escalate and
|
||||
Hold were simply not there. Wrapping drops them onto a second line
|
||||
instead of off the screen. */}
|
||||
<Group justify="space-between" wrap="wrap" gap="sm">
|
||||
{/* Left: where the application stands, and who has it. */}
|
||||
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Group gap="sm" wrap="wrap" style={{ minWidth: 0, flex: '1 1 auto' }}>
|
||||
<Badge color={STATUS_COLORS[status]} variant="light" size="lg">
|
||||
{t(`queue.statusValues.${status}`, STATUS_LABELS[status])}
|
||||
</Badge>
|
||||
@@ -124,7 +128,7 @@ export function DecisionBar({
|
||||
</Group>
|
||||
|
||||
{/* Right: the decision. */}
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Group gap="xs" wrap="wrap" justify="flex-end" style={{ flex: '0 1 auto' }}>
|
||||
{primary.map((action) => (
|
||||
<ActionButton
|
||||
key={action.id}
|
||||
@@ -194,10 +198,17 @@ function ActionButton({ action, busy, size, onAction }: ActionButtonProps) {
|
||||
const button = (
|
||||
<Button
|
||||
size={size}
|
||||
variant={action.emphasis === 'filled' ? 'filled' : action.emphasis ?? 'light'}
|
||||
variant={
|
||||
action.emphasis === 'filled'
|
||||
? 'filled'
|
||||
: action.emphasis === 'subtle'
|
||||
? 'default'
|
||||
: 'light'
|
||||
}
|
||||
color={action.color}
|
||||
loading={busy}
|
||||
disabled={!action.enabled}
|
||||
style={{ flexShrink: 0 }}
|
||||
onClick={() => onAction(action)}
|
||||
>
|
||||
{t(action.labelKey)}
|
||||
@@ -207,7 +218,13 @@ function ActionButton({ action, busy, size, onAction }: ActionButtonProps) {
|
||||
if (action.enabled) return button;
|
||||
|
||||
return (
|
||||
<Tooltip label={action.disabledReason} withArrow position="top">
|
||||
<Tooltip
|
||||
label={action.disabledReason}
|
||||
withArrow
|
||||
position="top"
|
||||
multiline
|
||||
w={280}
|
||||
>
|
||||
<span style={{ display: 'inline-flex', cursor: 'not-allowed' }}>{button}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
@@ -234,7 +251,13 @@ function MenuAction({
|
||||
);
|
||||
if (action.enabled) return item;
|
||||
return (
|
||||
<Tooltip label={action.disabledReason} withArrow position="left">
|
||||
<Tooltip
|
||||
label={action.disabledReason}
|
||||
withArrow
|
||||
position="left"
|
||||
multiline
|
||||
w={280}
|
||||
>
|
||||
<div>{item}</div>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
type DocumentRequirement,
|
||||
} from "@ema-platform/api";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { PdfPreviewModal } from "@ema-platform/ui";
|
||||
|
||||
interface DocumentsTabProps {
|
||||
applicationId: string;
|
||||
@@ -441,8 +442,20 @@ export function DocumentsTab({
|
||||
);
|
||||
})}
|
||||
|
||||
<PdfPreviewModal
|
||||
opened={Boolean(preview) && isPdf}
|
||||
onClose={() => setPreview(null)}
|
||||
url={previewFile?.url ?? ""}
|
||||
title={
|
||||
preview
|
||||
? localized(requirementByKey.get(preview.documentKey)?.name) ||
|
||||
preview.documentKey
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
|
||||
<Drawer
|
||||
opened={Boolean(preview)}
|
||||
opened={Boolean(preview) && !isPdf}
|
||||
onClose={() => setPreview(null)}
|
||||
position="right"
|
||||
size="xl"
|
||||
@@ -458,16 +471,7 @@ export function DocumentsTab({
|
||||
returnFocus
|
||||
>
|
||||
{previewFile?.url ? (
|
||||
isPdf ? (
|
||||
<iframe
|
||||
src={previewFile.url}
|
||||
title={
|
||||
preview?.documentKey ??
|
||||
t("review.documents.previewFallback", "document")
|
||||
}
|
||||
style={{ width: "100%", height: "80vh", border: "none" }}
|
||||
/>
|
||||
) : isImage ? (
|
||||
isImage ? (
|
||||
<img
|
||||
src={previewFile.url}
|
||||
alt={
|
||||
|
||||
@@ -268,11 +268,18 @@ export interface ResolveContext {
|
||||
needsFlags: string;
|
||||
needsCapital: string;
|
||||
needsInspection: string;
|
||||
needsDocumentReviews: string;
|
||||
};
|
||||
/** Number of sections/documents the officer has flagged for correction. */
|
||||
flaggedCount: number;
|
||||
/** True when an inspection is scheduled and awaiting a result. */
|
||||
hasPendingInspection: boolean;
|
||||
/**
|
||||
* False while any uploaded document is still unjudged or rejected. Approving
|
||||
* is a statement that every document was checked, so the button stays dead
|
||||
* until the officer has actually judged each one.
|
||||
*/
|
||||
allDocumentsAccepted: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -358,6 +365,13 @@ export function resolveActions(ctx: ResolveContext): ResolvedAction[] {
|
||||
return disabled(reasons.notAssigned);
|
||||
}
|
||||
|
||||
if (
|
||||
(action.id === 'approve-documents' || action.id === 'final-approve') &&
|
||||
!ctx.allDocumentsAccepted
|
||||
) {
|
||||
return disabled(reasons.needsDocumentReviews);
|
||||
}
|
||||
|
||||
if (action.id === 'request-adjustment' && ctx.flaggedCount === 0) {
|
||||
return disabled(reasons.needsFlags);
|
||||
}
|
||||
|
||||
@@ -120,14 +120,19 @@ export function licenseQueueColumns(
|
||||
{
|
||||
header: sortableHeader(t("queue.statusCol", "Status"), "status"),
|
||||
label: t("queue.statusCol", "Status"),
|
||||
cell: ({ row }) => (
|
||||
<Badge color={STATUS_COLORS[row.original.status]} variant="light">
|
||||
{t(
|
||||
`queue.statusValues.${row.original.status}`,
|
||||
STATUS_LABELS[row.original.status],
|
||||
)}
|
||||
</Badge>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const label = t(
|
||||
`queue.statusValues.${row.original.status}`,
|
||||
STATUS_LABELS[row.original.status],
|
||||
);
|
||||
return (
|
||||
<Tooltip label={label} withArrow>
|
||||
<Badge color={STATUS_COLORS[row.original.status]} variant="light">
|
||||
{label}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: sortableHeader(
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
useLazyExportApplicationsQuery,
|
||||
type LicenseApplication,
|
||||
type LicenseStatus,
|
||||
type LicenseType,
|
||||
type QueueFilter,
|
||||
} from "@ema-platform/api";
|
||||
import {
|
||||
@@ -75,6 +76,17 @@ import { licenseQueueActionsColumn } from "./actions";
|
||||
const PAGE_SIZE = 10;
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
|
||||
/**
|
||||
* Statuses only the STANDARD course can reach. A registration goes straight
|
||||
* review → approval, so offering these in its facet would be offering filters
|
||||
* that can only ever match nothing.
|
||||
*/
|
||||
const STANDARD_ONLY_STATUSES: LicenseStatus[] = [
|
||||
"UNDER_EVALUATION",
|
||||
"INSPECTION_PENDING",
|
||||
"INSPECTION_COMPLETED",
|
||||
];
|
||||
|
||||
const ALL_STATUSES: LicenseStatus[] = [
|
||||
"SUBMITTED",
|
||||
"UNDER_REVIEW",
|
||||
@@ -92,6 +104,24 @@ const ALL_STATUSES: LicenseStatus[] = [
|
||||
"REJECTED",
|
||||
];
|
||||
|
||||
/** Statuses an application of this type can actually occupy. */
|
||||
function statusesFor(type: LicenseType | undefined): LicenseStatus[] {
|
||||
if (!type) return ALL_STATUSES;
|
||||
return ALL_STATUSES.filter((status) => {
|
||||
if (
|
||||
type.workflowProfile === "REGISTRATION" &&
|
||||
STANDARD_ONLY_STATUSES.includes(status)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (status === "INSPECTION_PENDING" || status === "INSPECTION_COMPLETED") {
|
||||
return type.inspectionRequired;
|
||||
}
|
||||
if (status === "CERTIFICATE_ISSUED") return type.issuesCertificate;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The officer work pool.
|
||||
*
|
||||
@@ -153,14 +183,37 @@ export function LicenseQueuePage() {
|
||||
const activeView = SAVED_VIEWS.find((v) => v.id === view) ?? SAVED_VIEWS[0];
|
||||
|
||||
const { data: licenseTypes } = useGetLicenseTypesQuery();
|
||||
const { data: counts } = useGetQueueCountsQuery();
|
||||
|
||||
// A `/licence-review/type/:typeCode` deep link pins the type facet.
|
||||
const pinnedTypeId = useMemo(() => {
|
||||
if (!typeCode) return undefined;
|
||||
return licenseTypes?.items?.find((type) => type.key === typeCode)?.id;
|
||||
}, [typeCode, licenseTypes]);
|
||||
|
||||
// Counts endpoint keys off `key`, the facet off `id` — resolve whichever the
|
||||
// route or the dropdown set, so the tab badges always count the same rows
|
||||
// the grid is showing rather than system-wide totals.
|
||||
const countsKey = useMemo(
|
||||
() =>
|
||||
typeCode ??
|
||||
licenseTypes?.items?.find((type) => type.id === urlFilter.licenseTypeId)
|
||||
?.key,
|
||||
[typeCode, urlFilter.licenseTypeId, licenseTypes],
|
||||
);
|
||||
const { data: counts } = useGetQueueCountsQuery(countsKey);
|
||||
|
||||
const selectedType = useMemo(() => {
|
||||
const typeId = pinnedTypeId ?? urlFilter.licenseTypeId;
|
||||
if (!typeId) return undefined;
|
||||
return licenseTypes?.items?.find((type) => type.id === typeId);
|
||||
}, [pinnedTypeId, urlFilter.licenseTypeId, licenseTypes]);
|
||||
|
||||
// The facet offers what the chosen type can actually reach. With no type
|
||||
// chosen the queue spans every course, so the full list is correct.
|
||||
const statusOptions = useMemo(
|
||||
() => statusesFor(selectedType),
|
||||
[selectedType],
|
||||
);
|
||||
|
||||
const filter: QueueFilter = useMemo(
|
||||
() => ({
|
||||
...activeView.filter,
|
||||
@@ -264,6 +317,21 @@ export function LicenseQueuePage() {
|
||||
updateUrl(next, view, 1);
|
||||
};
|
||||
|
||||
/**
|
||||
* Switching type drops any selected status the new type cannot reach —
|
||||
* otherwise the facet keeps an invisible filter that matches nothing and the
|
||||
* grid looks empty for no reason the officer can see.
|
||||
*/
|
||||
const changeType = (typeId: string | undefined) => {
|
||||
const allowed = statusesFor(
|
||||
licenseTypes?.items?.find((type) => type.id === typeId),
|
||||
);
|
||||
setFacet({
|
||||
licenseTypeId: typeId,
|
||||
status: urlFilter.status?.filter((s) => allowed.includes(s)),
|
||||
});
|
||||
};
|
||||
|
||||
const toggleSort = (field: NonNullable<QueueFilter["sortBy"]>) => {
|
||||
const dir =
|
||||
urlFilter.sortBy === field && urlFilter.sortDir !== "DESC"
|
||||
@@ -494,7 +562,7 @@ export function LicenseQueuePage() {
|
||||
<MultiSelect
|
||||
label={t("queue.status", "Status")}
|
||||
placeholder={t("queue.anyStatus", "Any")}
|
||||
data={ALL_STATUSES.map((s) => ({
|
||||
data={statusOptions.map((s) => ({
|
||||
value: s,
|
||||
label: t(`queue.statusValues.${s}`, STATUS_LABELS[s]),
|
||||
}))}
|
||||
@@ -512,7 +580,7 @@ export function LicenseQueuePage() {
|
||||
label: localized(type.name, i18n.language) || type.key,
|
||||
}))}
|
||||
value={urlFilter.licenseTypeId ?? null}
|
||||
onChange={(v) => setFacet({ licenseTypeId: v ?? undefined })}
|
||||
onChange={(v) => changeType(v ?? undefined)}
|
||||
clearable
|
||||
w={220}
|
||||
/>
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
useFinalApproveMutation,
|
||||
useGetApplicationForReviewQuery,
|
||||
useGetAttachmentsQuery,
|
||||
useGetDocumentReviewsQuery,
|
||||
useGetInspectionsQuery,
|
||||
useGetAssignableOfficersQuery,
|
||||
useGetLicenseTypeRequirementsQuery,
|
||||
@@ -58,12 +59,14 @@ import {
|
||||
useResumeApplicationMutation,
|
||||
useScheduleInspectionMutation,
|
||||
type RemarkTargetType,
|
||||
type StaffEvidenceRequirement,
|
||||
} from "@ema-platform/api";
|
||||
import {
|
||||
AdvancedTable,
|
||||
AmharicDatePicker,
|
||||
ErrorState,
|
||||
ModalFooter,
|
||||
PdfPreviewModal,
|
||||
useServerTable,
|
||||
} from "@ema-platform/ui";
|
||||
import { useDateDisplayer } from "@ema-platform/shared";
|
||||
@@ -180,6 +183,19 @@ export function LicenseReviewPage() {
|
||||
),
|
||||
[requirements],
|
||||
);
|
||||
// What each role is *required* to produce (CV, work agreement, ERB
|
||||
// certificate). Without this the tab can only list what was uploaded, so a
|
||||
// missing CV looks identical to a role that never needed one.
|
||||
const evidenceByRole = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
requirements?.staffRoleRequirements.map((r) => [
|
||||
r.roleKey,
|
||||
r.requiredEvidence ?? [],
|
||||
]) ?? [],
|
||||
),
|
||||
[requirements],
|
||||
);
|
||||
|
||||
const [completeReview] = useCompleteReviewMutation();
|
||||
const [requestAdjustment] = useRequestAdjustmentMutation();
|
||||
@@ -200,6 +216,9 @@ export function LicenseReviewPage() {
|
||||
// Real officer list, so Assign and Escalate name a person instead of
|
||||
// silently reassigning to whoever already held the application.
|
||||
const { data: officers = [] } = useGetAssignableOfficersQuery();
|
||||
// Same cached query the Documents tab reads, so the decision bar reacts the
|
||||
// moment a verdict is saved.
|
||||
const { data: documentReviews = [] } = useGetDocumentReviewsQuery(id, { skip: !id });
|
||||
|
||||
const staffTable = useServerTable();
|
||||
const [flags, setFlags] = useState<FlagMap>({});
|
||||
@@ -253,7 +272,24 @@ export function LicenseReviewPage() {
|
||||
return map;
|
||||
}, [flags]);
|
||||
|
||||
const pendingInspection = inspections.find((i) => i.status === "SCHEDULED");
|
||||
const pendingInspection = inspections.find((i) => i.status === 'SCHEDULED');
|
||||
|
||||
// Approving means every uploaded document was accepted — one unjudged or
|
||||
// rejected file is enough to keep the decision buttons dead. The counts feed
|
||||
// the hover explanation, so the officer sees how much is left rather than
|
||||
// just that the button is dead.
|
||||
const documentProgress = useMemo(() => {
|
||||
const attachments = data?.attachments ?? [];
|
||||
const accepted = new Set(
|
||||
documentReviews
|
||||
.filter((review) => review.decision === 'ACCEPTED')
|
||||
.map((review) => review.documentKey),
|
||||
);
|
||||
const acceptedCount = attachments.filter((a) => accepted.has(a.documentKey)).length;
|
||||
return { acceptedCount, total: attachments.length };
|
||||
}, [data?.attachments, documentReviews]);
|
||||
const allDocumentsAccepted =
|
||||
documentProgress.total > 0 && documentProgress.acceptedCount === documentProgress.total;
|
||||
const flagged = Object.entries(flags);
|
||||
|
||||
/**
|
||||
@@ -293,31 +329,26 @@ export function LicenseReviewPage() {
|
||||
can,
|
||||
flaggedCount: flagged.length,
|
||||
hasPendingInspection: Boolean(pendingInspection),
|
||||
allDocumentsAccepted,
|
||||
reasons: {
|
||||
wrongStatus: t(
|
||||
"review.disabled.wrongStatus",
|
||||
"Not available at this stage",
|
||||
),
|
||||
notAssigned: t(
|
||||
"review.disabled.notAssigned",
|
||||
"Assigned to another officer",
|
||||
),
|
||||
noPermission: t(
|
||||
"review.disabled.noPermission",
|
||||
"You do not have permission",
|
||||
),
|
||||
needsFlags: t(
|
||||
"review.disabled.needsFlags",
|
||||
"Flag at least one item to request a correction",
|
||||
),
|
||||
needsCapital: t(
|
||||
"review.disabled.needsCapital",
|
||||
"Record the verified capital first",
|
||||
),
|
||||
needsInspection: t(
|
||||
"review.disabled.needsInspection",
|
||||
"Requires an inspection result",
|
||||
),
|
||||
wrongStatus: t('review.disabled.wrongStatus', 'Not available at this stage'),
|
||||
notAssigned: t('review.disabled.notAssigned', 'Assigned to another officer'),
|
||||
noPermission: t('review.disabled.noPermission', 'You do not have permission'),
|
||||
needsFlags: t('review.disabled.needsFlags', 'Flag at least one item to request a correction'),
|
||||
needsCapital: t('review.disabled.needsCapital', 'Record the verified capital first'),
|
||||
needsInspection: t('review.disabled.needsInspection', 'Requires an inspection result'),
|
||||
needsDocumentReviews:
|
||||
documentProgress.total === 0
|
||||
? t(
|
||||
'review.disabled.needsDocumentsUploaded',
|
||||
'No documents uploaded to review yet',
|
||||
)
|
||||
: t('review.disabled.needsDocumentReviews', {
|
||||
accepted: documentProgress.acceptedCount,
|
||||
total: documentProgress.total,
|
||||
defaultValue:
|
||||
'Accept all documents first — {{accepted}} of {{total}} accepted. Open the Documents tab and accept the rest.',
|
||||
}),
|
||||
},
|
||||
});
|
||||
}, [data, currentUserId, can, flagged.length, pendingInspection, t]);
|
||||
@@ -996,6 +1027,7 @@ export function LicenseReviewPage() {
|
||||
renderEvidence: (member) => (
|
||||
<StaffEvidenceCell
|
||||
staffId={member.id}
|
||||
required={evidenceByRole.get(member.roleKey) ?? []}
|
||||
fallback={member.documents}
|
||||
/>
|
||||
),
|
||||
@@ -1344,36 +1376,96 @@ export function LicenseReviewPage() {
|
||||
*/
|
||||
function StaffEvidenceCell({
|
||||
staffId,
|
||||
required,
|
||||
fallback,
|
||||
}: {
|
||||
staffId: string;
|
||||
/** What this person's role must produce, from the licence-type config. */
|
||||
required: StaffEvidenceRequirement[];
|
||||
fallback?: { id: string; documentKey: string; files: { url?: string }[] }[];
|
||||
}) {
|
||||
const localized = useLocalized();
|
||||
const { t } = useTranslation();
|
||||
const [preview, setPreview] = useState<{ url: string; title: string } | null>(
|
||||
null,
|
||||
);
|
||||
const { data: attachments } = useGetAttachmentsQuery({
|
||||
ownerType: "APPLICATION_STAFF",
|
||||
ownerId: staffId,
|
||||
});
|
||||
const docs = attachments?.length ? attachments : (fallback ?? []);
|
||||
const uploadedBy = new Map(docs.map((d) => [d.documentKey, d]));
|
||||
|
||||
// Drive the list off the requirements, not off what happens to have been
|
||||
// uploaded: a mandatory CV that is absent has to be visible as absent, which
|
||||
// is the whole point of the officer looking at this column. Anything
|
||||
// uploaded outside the list still gets shown rather than silently dropped.
|
||||
const extras = docs.filter(
|
||||
(d) => !required.some((r) => r.docKey === d.documentKey),
|
||||
);
|
||||
|
||||
if (!required.length && !extras.length) {
|
||||
return (
|
||||
<Text size="xs" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const badge = (
|
||||
key: string,
|
||||
label: string,
|
||||
url: string | undefined,
|
||||
mandatory: boolean,
|
||||
) => {
|
||||
const missing = !url;
|
||||
return (
|
||||
<Tooltip
|
||||
key={key}
|
||||
label={
|
||||
missing
|
||||
? mandatory
|
||||
? t("review.evidenceMissingRequired", "Required — not uploaded")
|
||||
: t("review.evidenceMissing", "Not uploaded")
|
||||
: t("licensing.documents.view", "View")
|
||||
}
|
||||
withArrow
|
||||
>
|
||||
<Badge
|
||||
size="xs"
|
||||
variant={missing ? "outline" : "light"}
|
||||
color={missing ? (mandatory ? "red" : "gray") : "teal"}
|
||||
style={missing ? undefined : { cursor: "pointer" }}
|
||||
onClick={
|
||||
missing ? undefined : () => setPreview({ url: url, title: label })
|
||||
}
|
||||
>
|
||||
{label}
|
||||
{missing && mandatory ? " *" : ""}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Group gap={4}>
|
||||
{docs.map((doc) => {
|
||||
const url = doc.files?.[0]?.url;
|
||||
return (
|
||||
<Badge
|
||||
key={doc.id}
|
||||
size="xs"
|
||||
variant="light"
|
||||
component={url ? "a" : undefined}
|
||||
href={url}
|
||||
target={url ? "_blank" : undefined}
|
||||
rel={url ? "noreferrer" : undefined}
|
||||
style={url ? { cursor: "pointer" } : undefined}
|
||||
>
|
||||
{doc.documentKey}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
{required.map((item) =>
|
||||
badge(
|
||||
item.docKey,
|
||||
localized(item.label) || item.docKey,
|
||||
uploadedBy.get(item.docKey)?.files?.[0]?.url,
|
||||
item.mandatory,
|
||||
),
|
||||
)}
|
||||
{extras.map((doc) =>
|
||||
badge(doc.id, doc.documentKey, doc.files?.[0]?.url, false),
|
||||
)}
|
||||
<PdfPreviewModal
|
||||
opened={Boolean(preview)}
|
||||
onClose={() => setPreview(null)}
|
||||
url={preview?.url ?? ""}
|
||||
title={preview?.title}
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
Box,
|
||||
rem,
|
||||
Center,
|
||||
useMantineColorScheme,
|
||||
useComputedColorScheme,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconChevronRight,
|
||||
@@ -23,6 +23,7 @@ import { useGetLocationsQuery } from '../api/location-api';
|
||||
import type { Location } from '../types/location';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocalized } from '@ema-platform/api';
|
||||
import { PageLoader } from '@ema-platform/ui';
|
||||
|
||||
interface LocationTreeProps {
|
||||
selectedId: string | null;
|
||||
@@ -57,7 +58,7 @@ function TreeNode({
|
||||
const isSelected = selectedId === location.id;
|
||||
const hasChildren =
|
||||
Array.isArray(location.children) && location.children.length > 0;
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const colorScheme = useComputedColorScheme('light', { getInitialValueInEffect: true });
|
||||
const hoverBg = colorScheme === 'dark'
|
||||
? 'var(--mantine-color-dark-6)'
|
||||
: 'var(--mantine-color-gray-0)';
|
||||
@@ -207,11 +208,7 @@ export function LocationTree({
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center py="xl">
|
||||
<Loader />
|
||||
</Center>
|
||||
);
|
||||
return <PageLoader label="Loading Locations…" height={300} />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { IconSettings, IconMap, IconInfoCircle, IconPlus } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { notify, useErrorHandler, ModalFooter } from '@ema-platform/ui';
|
||||
import { notify, useErrorHandler, ModalFooter, PageLoader } from '@ema-platform/ui';
|
||||
import { LocationTree } from '../components/LocationTree';
|
||||
import { LocationDetail } from '../components/LocationDetail';
|
||||
import { LocationForm } from '../components/LocationForm';
|
||||
@@ -103,11 +103,7 @@ export function LocationPage() {
|
||||
}, [selectedLocation, deleteLocation, closeDeleteModal, handleError]);
|
||||
|
||||
if (typesLoading) {
|
||||
return (
|
||||
<Center py="xl">
|
||||
<Loader />
|
||||
</Center>
|
||||
);
|
||||
return <PageLoader label="Loading Location Types…" height={400} />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconChevronRight } from '@tabler/icons-react';
|
||||
import { AdvancedTable, useServerTable } from '@ema-platform/ui';
|
||||
import { AdvancedTable, useServerTable, PageLoader } from '@ema-platform/ui';
|
||||
import {
|
||||
STATUS_COLORS,
|
||||
STATUS_LABELS,
|
||||
@@ -37,11 +37,7 @@ export function LogisticsHeadDashboardPage() {
|
||||
const table = useServerTable();
|
||||
|
||||
if (queue.isLoading || mine.isLoading) {
|
||||
return (
|
||||
<Center h={300}>
|
||||
<Loader />
|
||||
</Center>
|
||||
);
|
||||
return <PageLoader label="Loading Dashboard…" height={400} />;
|
||||
}
|
||||
|
||||
const unclaimed = queue.data?.items ?? [];
|
||||
|
||||
@@ -33,24 +33,28 @@ export function medicalActionsColumn(
|
||||
anyOf={[LICENSE_PERMISSIONS.VERIFY_SEAFARER_RECORDS]}
|
||||
hideOnly
|
||||
>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="teal"
|
||||
leftSection={<IconCheck size={14} />}
|
||||
loading={handlers.ruling}
|
||||
onClick={() => handlers.onVerify(row.original)}
|
||||
>
|
||||
{t('recordVerification.verify', 'Verify')}
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="red"
|
||||
variant="light"
|
||||
leftSection={<IconX size={14} />}
|
||||
onClick={() => handlers.onReject(row.original)}
|
||||
>
|
||||
{t('recordVerification.reject', 'Reject')}
|
||||
</Button>
|
||||
{row.original.status === 'SUBMITTED' && (
|
||||
<>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="teal"
|
||||
leftSection={<IconCheck size={14} />}
|
||||
loading={handlers.ruling}
|
||||
onClick={() => handlers.onVerify(row.original)}
|
||||
>
|
||||
{t('recordVerification.verify', 'Verify')}
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="red"
|
||||
variant="light"
|
||||
leftSection={<IconX size={14} />}
|
||||
onClick={() => handlers.onReject(row.original)}
|
||||
>
|
||||
{t('recordVerification.reject', 'Reject')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</RequirePermission>
|
||||
</Group>
|
||||
),
|
||||
@@ -85,24 +89,28 @@ export function seaServiceActionsColumn(
|
||||
anyOf={[LICENSE_PERMISSIONS.VERIFY_SEAFARER_RECORDS]}
|
||||
hideOnly
|
||||
>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="teal"
|
||||
leftSection={<IconCheck size={14} />}
|
||||
loading={handlers.ruling}
|
||||
onClick={() => handlers.onVerify(row.original)}
|
||||
>
|
||||
{t('recordVerification.verify', 'Verify')}
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="red"
|
||||
variant="light"
|
||||
leftSection={<IconX size={14} />}
|
||||
onClick={() => handlers.onReject(row.original)}
|
||||
>
|
||||
{t('recordVerification.reject', 'Reject')}
|
||||
</Button>
|
||||
{row.original.status === 'SUBMITTED' && (
|
||||
<>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="teal"
|
||||
leftSection={<IconCheck size={14} />}
|
||||
loading={handlers.ruling}
|
||||
onClick={() => handlers.onVerify(row.original)}
|
||||
>
|
||||
{t('recordVerification.verify', 'Verify')}
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="red"
|
||||
variant="light"
|
||||
leftSection={<IconX size={14} />}
|
||||
onClick={() => handlers.onReject(row.original)}
|
||||
>
|
||||
{t('recordVerification.reject', 'Reject')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</RequirePermission>
|
||||
</Group>
|
||||
),
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
MedicalCertificate,
|
||||
SeaServiceRecord,
|
||||
SeafarerProfileSummary,
|
||||
SeafarerRecordStatus,
|
||||
} from '@ema-platform/api';
|
||||
|
||||
export function ownerName(profile?: SeafarerProfileSummary): string {
|
||||
@@ -16,6 +17,28 @@ export function ownerName(profile?: SeafarerProfileSummary): string {
|
||||
);
|
||||
}
|
||||
|
||||
const STATUS_COLOR: Record<SeafarerRecordStatus, string> = {
|
||||
SUBMITTED: 'yellow',
|
||||
VERIFIED: 'teal',
|
||||
REJECTED: 'red',
|
||||
};
|
||||
|
||||
/** Only meaningful now the queue can show ruled records too. */
|
||||
function statusColumn<T extends { status: SeafarerRecordStatus }>(
|
||||
t: TFunction,
|
||||
): AdvancedColumn<T> {
|
||||
return {
|
||||
header: t('recordVerification.columns.status', 'Status'),
|
||||
label: t('recordVerification.columns.status', 'Status'),
|
||||
accessorKey: 'status',
|
||||
cell: ({ row }) => (
|
||||
<Badge size="sm" variant="light" color={STATUS_COLOR[row.original.status]}>
|
||||
{t(`recordVerification.status.${row.original.status}`, row.original.status)}
|
||||
</Badge>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function medicalColumns(
|
||||
t: TFunction,
|
||||
showDate: (date: string) => string,
|
||||
@@ -72,6 +95,7 @@ export function medicalColumns(
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
statusColumn<MedicalCertificate>(t),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -127,5 +151,6 @@ export function seaServiceColumns(
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
statusColumn<SeaServiceRecord>(t),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Loader,
|
||||
Modal,
|
||||
Paper,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
@@ -22,7 +23,12 @@ import {
|
||||
IconPaperclip,
|
||||
IconStethoscope,
|
||||
} from '@tabler/icons-react';
|
||||
import { AdvancedTable, notify, type AdvancedColumn } from '@ema-platform/ui';
|
||||
import {
|
||||
AdvancedTable,
|
||||
notify,
|
||||
PdfPreviewModal,
|
||||
type AdvancedColumn,
|
||||
} from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
@@ -32,7 +38,11 @@ import {
|
||||
useVerifyMedicalCertificateMutation,
|
||||
useVerifySeaServiceRecordMutation,
|
||||
} from '@ema-platform/api';
|
||||
import type { MedicalCertificate, SeaServiceRecord } from '@ema-platform/api';
|
||||
import type {
|
||||
MedicalCertificate,
|
||||
RecordQueueFilter,
|
||||
SeaServiceRecord,
|
||||
} from '@ema-platform/api';
|
||||
import { medicalColumns, seaServiceColumns, ownerName } from './columns';
|
||||
import { medicalActionsColumn, seaServiceActionsColumn } from './actions';
|
||||
|
||||
@@ -104,6 +114,9 @@ function AttachmentsModal({
|
||||
{ ownerType, ownerId: ownerId ?? '' },
|
||||
{ skip: !ownerId },
|
||||
);
|
||||
const [preview, setPreview] = useState<{ url: string; title: string } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const files = useMemo(
|
||||
() => (attachments ?? []).flatMap((a) => a.files ?? []),
|
||||
@@ -151,10 +164,9 @@ function AttachmentsModal({
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
leftSection={<IconEye size={14} />}
|
||||
component="a"
|
||||
href={file.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() =>
|
||||
setPreview({ url: file.url as string, title: file.originalName })
|
||||
}
|
||||
>
|
||||
{t('recordVerification.view', 'View')}
|
||||
</Button>
|
||||
@@ -168,6 +180,12 @@ function AttachmentsModal({
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
<PdfPreviewModal
|
||||
opened={Boolean(preview)}
|
||||
onClose={() => setPreview(null)}
|
||||
url={preview?.url ?? ''}
|
||||
title={preview?.title}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -180,19 +198,20 @@ function AttachmentsModal({
|
||||
*/
|
||||
export function MedicalVerificationPage() {
|
||||
const { t } = useTranslation();
|
||||
const [filter, setFilter] = useState<RecordQueueFilter>('SUBMITTED');
|
||||
const {
|
||||
data: pendingMedical,
|
||||
isLoading: loadingMedical,
|
||||
isFetching: fetchingMedical,
|
||||
refetch: refetchMedical,
|
||||
} = useGetPendingMedicalQuery();
|
||||
} = useGetPendingMedicalQuery(filter);
|
||||
|
||||
const {
|
||||
data: pendingSeaService,
|
||||
isLoading: loadingSeaService,
|
||||
isFetching: fetchingSeaService,
|
||||
refetch: refetchSeaService,
|
||||
} = useGetPendingSeaServiceQuery();
|
||||
} = useGetPendingSeaServiceQuery(filter);
|
||||
|
||||
const [verifyMedical, { isLoading: rulingMedical }] =
|
||||
useVerifyMedicalCertificateMutation();
|
||||
@@ -251,6 +270,12 @@ export function MedicalVerificationPage() {
|
||||
return pendingSeaServiceList.slice(start, start + seaServicePageSize);
|
||||
}, [pendingSeaServiceList, seaServicePage, seaServicePageSize]);
|
||||
|
||||
const changeFilter = useCallback((value: string) => {
|
||||
setFilter(value as RecordQueueFilter);
|
||||
setMedicalPage(0);
|
||||
setSeaServicePage(0);
|
||||
}, []);
|
||||
|
||||
const handleMedicalPageSizeChange = useCallback((size: number) => {
|
||||
setMedicalPageSize(size);
|
||||
setMedicalPage(0);
|
||||
@@ -261,6 +286,34 @@ export function MedicalVerificationPage() {
|
||||
setSeaServicePage(0);
|
||||
}, []);
|
||||
|
||||
const statusFilter = (
|
||||
<SegmentedControl
|
||||
mb="md"
|
||||
value={filter}
|
||||
onChange={changeFilter}
|
||||
data={[
|
||||
{
|
||||
value: 'SUBMITTED',
|
||||
label: t('recordVerification.filter.pending', 'Pending'),
|
||||
},
|
||||
{
|
||||
value: 'VERIFIED',
|
||||
label: t('recordVerification.filter.verified', 'Accepted'),
|
||||
},
|
||||
{
|
||||
value: 'REJECTED',
|
||||
label: t('recordVerification.filter.rejected', 'Rejected'),
|
||||
},
|
||||
{ value: 'ALL', label: t('recordVerification.filter.all', 'All') },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
const emptyText =
|
||||
filter === 'SUBMITTED'
|
||||
? t('recordVerification.emptyText', 'Nothing awaiting verification.')
|
||||
: t('recordVerification.emptyTextFiltered', 'No records match this filter.');
|
||||
|
||||
const medicalTableColumns: AdvancedColumn<MedicalCertificate>[] = useMemo(
|
||||
() => [
|
||||
...medicalColumns(t, showDate),
|
||||
@@ -348,6 +401,7 @@ export function MedicalVerificationPage() {
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="medical" pt="md">
|
||||
{statusFilter}
|
||||
<AdvancedTable
|
||||
columns={medicalTableColumns}
|
||||
data={pagedMedical}
|
||||
@@ -362,11 +416,12 @@ export function MedicalVerificationPage() {
|
||||
onPageSizeChange={handleMedicalPageSizeChange}
|
||||
refresh={refetchMedical}
|
||||
isLoading={loadingMedical || fetchingMedical}
|
||||
emptyText={t('recordVerification.emptyText', 'Nothing awaiting verification.')}
|
||||
emptyText={emptyText}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="sea-service" pt="md">
|
||||
{statusFilter}
|
||||
<AdvancedTable
|
||||
columns={seaServiceTableColumns}
|
||||
data={pagedSeaService}
|
||||
@@ -381,7 +436,7 @@ export function MedicalVerificationPage() {
|
||||
onPageSizeChange={handleSeaServicePageSizeChange}
|
||||
refresh={refetchSeaService}
|
||||
isLoading={loadingSeaService || fetchingSeaService}
|
||||
emptyText={t('recordVerification.emptyText', 'Nothing awaiting verification.')}
|
||||
emptyText={emptyText}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
AdvancedTable,
|
||||
useServerTable,
|
||||
type AdvancedColumn,
|
||||
PageLoader,
|
||||
} from '@ema-platform/ui';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
@@ -64,11 +65,7 @@ export function PaymentConfigPage() {
|
||||
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center h={300}>
|
||||
<Loader />
|
||||
</Center>
|
||||
);
|
||||
return <PageLoader label="Loading Payment Configuration…" height={400} />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
|
||||
@@ -35,6 +35,18 @@
|
||||
box-shadow: var(--mantine-shadow-xs);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme='dark'] .list {
|
||||
background: var(--mantine-color-dark-6);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme='dark'] .tab {
|
||||
color: var(--mantine-color-dark-1);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme='dark'] .tab:hover {
|
||||
color: var(--mantine-color-white);
|
||||
}
|
||||
|
||||
/* Selectable option card (language + appearance). */
|
||||
.choice {
|
||||
border: 1px solid var(--mantine-color-gray-3);
|
||||
@@ -49,8 +61,21 @@
|
||||
border-color: var(--mantine-color-gray-4);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme='dark'] .choice {
|
||||
border-color: var(--mantine-color-dark-4);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme='dark'] .choice:hover {
|
||||
border-color: var(--mantine-color-dark-3);
|
||||
}
|
||||
|
||||
.choiceActive,
|
||||
.choiceActive:hover {
|
||||
border-color: var(--mantine-color-emaPrimary-6);
|
||||
background: var(--mantine-color-emaPrimary-0);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme='dark'] .choiceActive,
|
||||
[data-mantine-color-scheme='dark'] .choiceActive:hover {
|
||||
background: var(--mantine-color-dark-6);
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ import { z } from 'zod';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { notify, PageHeader, useErrorHandler, passwordSchema as strongPasswordSchema, PasswordRequirements } from '@ema-platform/ui';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import { setUser } from '@ema-platform/auth';
|
||||
import { ActiveSessions, setUser } from '@ema-platform/auth';
|
||||
import type { AuthUser } from '@ema-platform/auth';
|
||||
import { SUPPORTED_LANGUAGES, type AppLanguage } from '../../../i18n/config';
|
||||
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
|
||||
@@ -87,7 +87,16 @@ export function ProfilePage() {
|
||||
const [isSavingPassword, setIsSavingPassword] = useState(false);
|
||||
|
||||
// UI-only preferences (no backend wiring yet).
|
||||
// Two-step verification is wired but parked for the testing phase: turning it
|
||||
// on makes every sign-in require an OTP. Swap this back for `useTwoFactor()`
|
||||
// to re-enable it (the login/OTP side already handles `mfaRequired`).
|
||||
const [twoStepEnabled, setTwoStepEnabled] = useState(false);
|
||||
// const {
|
||||
// enabled: twoStepEnabled,
|
||||
// isLoading: twoStepLoading,
|
||||
// isSaving: twoStepSaving,
|
||||
// setEnabled: setTwoStepEnabled,
|
||||
// } = useTwoFactor();
|
||||
const [emailNotifications, setEmailNotifications] = useState(true);
|
||||
|
||||
// Load the latest profile from the server on mount so the form always
|
||||
@@ -395,8 +404,9 @@ export function ProfilePage() {
|
||||
|
||||
{/* ---- Security ---- */}
|
||||
<Tabs.Panel value="security" pt="md">
|
||||
<Paper p="xl" shadow="sm" radius="lg" withBorder>
|
||||
<form onSubmit={handlePasswordSubmit(onChangePassword)}>
|
||||
<Stack gap="lg">
|
||||
<Paper p="xl" shadow="sm" radius="lg" withBorder>
|
||||
<form onSubmit={handlePasswordSubmit(onChangePassword)}>
|
||||
<Stack gap="xl">
|
||||
<div>
|
||||
<Title order={5}>{t('profile.security')}</Title>
|
||||
@@ -449,7 +459,7 @@ export function ProfilePage() {
|
||||
backgroundColor:
|
||||
i <= score
|
||||
? `var(--mantine-color-${strengthColors[score]}-6)`
|
||||
: 'var(--mantine-color-gray-2)',
|
||||
: 'var(--mantine-color-default-border)',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
@@ -471,6 +481,15 @@ export function ProfilePage() {
|
||||
<Switch
|
||||
checked={twoStepEnabled}
|
||||
onChange={(e) => setTwoStepEnabled(e.currentTarget.checked)}
|
||||
// disabled={twoStepLoading || twoStepSaving}
|
||||
// onChange={async (e) => {
|
||||
// try {
|
||||
// await setTwoStepEnabled(e.currentTarget.checked);
|
||||
// notify.success(t('profile.twoStep.saved'));
|
||||
// } catch (err) {
|
||||
// handleError(err);
|
||||
// }
|
||||
// }}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
@@ -484,8 +503,11 @@ export function ProfilePage() {
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Paper>
|
||||
</form>
|
||||
</Paper>
|
||||
|
||||
<ActiveSessions />
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* ---- Preferences ---- */}
|
||||
@@ -525,7 +547,7 @@ export function ProfilePage() {
|
||||
) : (
|
||||
<IconCircle
|
||||
size={20}
|
||||
color="var(--mantine-color-gray-4)"
|
||||
color="var(--mantine-color-dimmed)"
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
@@ -558,7 +580,7 @@ export function ProfilePage() {
|
||||
color={
|
||||
active
|
||||
? 'var(--mantine-color-emaPrimary-6)'
|
||||
: 'var(--mantine-color-gray-6)'
|
||||
: 'var(--mantine-color-dimmed)'
|
||||
}
|
||||
/>
|
||||
<Text fw={600} size="sm" style={{ flex: 1 }}>
|
||||
@@ -600,7 +622,7 @@ export function ProfilePage() {
|
||||
color={
|
||||
active
|
||||
? 'var(--mantine-color-emaPrimary-6)'
|
||||
: 'var(--mantine-color-gray-6)'
|
||||
: 'var(--mantine-color-dimmed)'
|
||||
}
|
||||
/>
|
||||
<Text fw={600} size="sm" style={{ flex: 1 }}>
|
||||
@@ -623,7 +645,7 @@ export function ProfilePage() {
|
||||
|
||||
<Group align="flex-start" justify="space-between" wrap="nowrap">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<IconBell size={20} color="var(--mantine-color-gray-6)" />
|
||||
<IconBell size={20} color="var(--mantine-color-dimmed)" />
|
||||
<div>
|
||||
<Text fw={600}>{t('profile.notifications.title')}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconInfoCircle } from '@tabler/icons-react';
|
||||
import { AdvancedTable, notify, useServerTable } from '@ema-platform/ui';
|
||||
import { AdvancedTable, notify, useServerTable, PageLoader } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import { extractErrorMessage } from '@ema-platform/api';
|
||||
import {
|
||||
@@ -61,11 +61,7 @@ export function ExamAppealsPage() {
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center py="xl">
|
||||
<Loader />
|
||||
</Center>
|
||||
);
|
||||
return <PageLoader label="Loading Exam Appeals…" height={400} />;
|
||||
}
|
||||
if (isError) {
|
||||
return (
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { Container } from '@mantine/core';
|
||||
import { FeatureUnavailable } from '@ema-platform/ui';
|
||||
|
||||
/**
|
||||
* Placeholder until this feature has a backend.
|
||||
*
|
||||
* This page previously rendered hardcoded sample records, which were
|
||||
* indistinguishable from real ones.
|
||||
*/
|
||||
export function VesselRegistrationHeadDashboardPage() {
|
||||
return (
|
||||
<Container size="lg" py="xl">
|
||||
<FeatureUnavailable
|
||||
title="Vessel registration overview"
|
||||
description="Vessel registration is not connected to the backend yet, so there are no figures to report."
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default VesselRegistrationHeadDashboardPage;
|
||||
@@ -109,7 +109,7 @@ function VesselDetailDrawer({
|
||||
</Text>
|
||||
</Group>
|
||||
{loadingIncidents ? (
|
||||
<Loader size="sm" />
|
||||
<Loader size="sm" type="oval" />
|
||||
) : (incidents ?? []).length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No incidents recorded.
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { Container } from '@mantine/core';
|
||||
import { FeatureUnavailable } from '@ema-platform/ui';
|
||||
|
||||
/**
|
||||
* Placeholder until this feature has a backend.
|
||||
*
|
||||
* This page previously rendered hardcoded sample records, which were
|
||||
* indistinguishable from real ones.
|
||||
*/
|
||||
export function VesselRegistrationReportPage() {
|
||||
return (
|
||||
<Container size="lg" py="xl">
|
||||
<FeatureUnavailable
|
||||
title="Vessel registration report"
|
||||
description="Vessel registration is not connected to the backend yet, so there is nothing to report on."
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default VesselRegistrationReportPage;
|
||||
@@ -0,0 +1,171 @@
|
||||
import { Badge, Card, Group, SimpleGrid, Text, Tooltip } from '@mantine/core';
|
||||
import {
|
||||
IconAlarm,
|
||||
IconAnchor,
|
||||
IconCalendarStats,
|
||||
IconCoin,
|
||||
IconClockHour4,
|
||||
IconScale,
|
||||
IconShip,
|
||||
IconThumbUp,
|
||||
type Icon,
|
||||
} from '@tabler/icons-react';
|
||||
import type { VesselReport } from '@ema-platform/api';
|
||||
import {
|
||||
DASH,
|
||||
deltaColor,
|
||||
formatDelta,
|
||||
formatMoney,
|
||||
formatNumber,
|
||||
formatPercent,
|
||||
} from './report-format';
|
||||
|
||||
interface TileProps {
|
||||
icon: Icon;
|
||||
label: string;
|
||||
value: string;
|
||||
/** The second line: what the headline figure is made of. */
|
||||
detail?: string;
|
||||
/** Hover text for anything the headline alone would misrepresent. */
|
||||
hint?: string;
|
||||
delta?: { text: string; color: string };
|
||||
color?: string;
|
||||
}
|
||||
|
||||
function Tile({ icon: TileIcon, label, value, detail, hint, delta, color = 'blue' }: TileProps) {
|
||||
const card = (
|
||||
<Card withBorder radius="md" p="md">
|
||||
<Group justify="space-between" wrap="nowrap" mb={4}>
|
||||
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lh={1.3}>
|
||||
{label}
|
||||
</Text>
|
||||
<TileIcon size={18} stroke={1.6} color={`var(--mantine-color-${color}-6)`} />
|
||||
</Group>
|
||||
<Group gap="xs" align="baseline" wrap="nowrap">
|
||||
<Text fz={26} fw={700} lh={1.1}>
|
||||
{value}
|
||||
</Text>
|
||||
{delta && (
|
||||
<Badge size="sm" variant="light" color={delta.color}>
|
||||
{delta.text}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
{detail && (
|
||||
<Text size="xs" c="dimmed" mt={6} lh={1.4}>
|
||||
{detail}
|
||||
</Text>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
|
||||
return hint ? (
|
||||
<Tooltip label={hint} multiline w={260} withArrow>
|
||||
{card}
|
||||
</Tooltip>
|
||||
) : (
|
||||
card
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The headline figures.
|
||||
*
|
||||
* Two different scopes sit side by side here and the labels have to keep them
|
||||
* apart: the register totals describe the whole book regardless of the date
|
||||
* filter, while "new in period" and the pipeline figures answer to it. A tile
|
||||
* reading "12 vessels" under a one-month filter would be taken for the size of
|
||||
* the national fleet.
|
||||
*/
|
||||
export function KpiTiles({ report }: { report: VesselReport }) {
|
||||
const { register, fleet, pipeline, certificates, revenue } = report.kpis;
|
||||
|
||||
return (
|
||||
<SimpleGrid cols={{ base: 1, xs: 2, md: 4 }} spacing="md">
|
||||
<Tile
|
||||
icon={IconShip}
|
||||
label="Vessels on the register"
|
||||
value={formatNumber(register.total)}
|
||||
detail={`${formatNumber(register.registered)} registered · ${formatNumber(register.suspended)} suspended · ${formatNumber(register.deregistered)} deregistered`}
|
||||
hint="The whole register. Not affected by the date filter."
|
||||
/>
|
||||
<Tile
|
||||
icon={IconCalendarStats}
|
||||
color="teal"
|
||||
label="New in period"
|
||||
value={formatNumber(register.registeredInPeriod)}
|
||||
detail={`${formatNumber(register.registeredInPreviousPeriod)} in the previous period`}
|
||||
delta={{
|
||||
text: formatDelta(register.changePct),
|
||||
color: deltaColor(register.changePct),
|
||||
}}
|
||||
hint="Vessels entered on the register inside the selected window, against the equally long window before it."
|
||||
/>
|
||||
<Tile
|
||||
icon={IconScale}
|
||||
color="indigo"
|
||||
label="Fleet tonnage"
|
||||
value={formatNumber(fleet.totalGrossTonnage)}
|
||||
// The coverage count is not decoration: an average over 2 of 300 hulls
|
||||
// is a different claim from an average over all of them.
|
||||
detail={`avg ${formatNumber(fleet.avgGrossTonnage, { decimals: 1 })} GT across ${formatNumber(fleet.grossTonnageKnownFor)} of ${formatNumber(register.total)} vessels`}
|
||||
hint="Gross tonnage is optional on the register, so the average covers only the vessels that declared one."
|
||||
/>
|
||||
<Tile
|
||||
icon={IconAnchor}
|
||||
color="cyan"
|
||||
label="Average age"
|
||||
value={
|
||||
fleet.avgAgeYears === null
|
||||
? DASH
|
||||
: formatNumber(fleet.avgAgeYears, { decimals: 1, suffix: ' yrs' })
|
||||
}
|
||||
detail={`${formatNumber(fleet.seaGoing)} sea-going · ${formatNumber(fleet.inlandWaterway)} inland · known for ${formatNumber(fleet.ageKnownFor)}`}
|
||||
hint="Derived from the build year, which not every entry carries."
|
||||
/>
|
||||
<Tile
|
||||
icon={IconThumbUp}
|
||||
color="green"
|
||||
label="Approval rate"
|
||||
value={formatPercent(pipeline.approvalRatePct)}
|
||||
detail={`${formatNumber(pipeline.approved)} approved · ${formatNumber(pipeline.rejected)} rejected · ${formatNumber(pipeline.inProgress)} in flight`}
|
||||
hint="Approved as a share of decided applications. Drafts and applications still in the queue are excluded."
|
||||
/>
|
||||
<Tile
|
||||
icon={IconClockHour4}
|
||||
color="grape"
|
||||
label="Processing time"
|
||||
value={
|
||||
pipeline.medianProcessingDays === null
|
||||
? DASH
|
||||
: formatNumber(pipeline.medianProcessingDays, {
|
||||
decimals: 1,
|
||||
suffix: ' d',
|
||||
})
|
||||
}
|
||||
detail={`median · mean ${formatNumber(pipeline.avgProcessingDays, { decimals: 1, suffix: ' d' })} · ${formatNumber(pipeline.avgAdjustmentRounds, { decimals: 2 })} adjustment rounds`}
|
||||
hint="Submission to decision. Only applications that have been decided are counted."
|
||||
/>
|
||||
<Tile
|
||||
icon={IconAlarm}
|
||||
color="orange"
|
||||
label="Certificates expiring"
|
||||
value={formatNumber(certificates.expiringIn30)}
|
||||
detail={`within 30 days · ${formatNumber(certificates.expiringIn60)} within 60 · ${formatNumber(certificates.expiringIn90)} within 90`}
|
||||
hint="Cumulative: a certificate due in a fortnight is counted in all three figures."
|
||||
/>
|
||||
<Tile
|
||||
icon={IconCoin}
|
||||
color="yellow"
|
||||
label="Fees collected"
|
||||
value={formatMoney(revenue.paid, revenue.currency)}
|
||||
detail={`${formatMoney(revenue.pending, revenue.currency)} outstanding · ${formatNumber(revenue.failedCount)} failed`}
|
||||
hint={
|
||||
revenue.mixedCurrency
|
||||
? 'The register holds payments in more than one currency; this total sums across them.'
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Card, Group, SimpleGrid, Text } from '@mantine/core';
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
Legend,
|
||||
Line,
|
||||
LineChart,
|
||||
Pie,
|
||||
PieChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import type { BreakdownItem, ReportGranularity, VesselReport } from '@ema-platform/api';
|
||||
import {
|
||||
expiryBands,
|
||||
formatBucket,
|
||||
formatNumber,
|
||||
officerLabel,
|
||||
sliceColor,
|
||||
} from './report-format';
|
||||
|
||||
// Recharts is unused elsewhere in this repo, so the shared setup lives here
|
||||
// rather than being repeated per chart: one grid style, one tooltip style, one
|
||||
// axis style, and a fixed height so the dashboard's rows line up.
|
||||
const CHART_HEIGHT = 260;
|
||||
const AXIS = { fontSize: 11, stroke: 'var(--mantine-color-dimmed)' } as const;
|
||||
const GRID = 'var(--mantine-color-default-border)';
|
||||
|
||||
const TOOLTIP_STYLE = {
|
||||
background: 'var(--mantine-color-body)',
|
||||
border: '1px solid var(--mantine-color-default-border)',
|
||||
borderRadius: 8,
|
||||
fontSize: 12,
|
||||
} as const;
|
||||
|
||||
function ChartCard({
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
empty,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
children: ReactNode;
|
||||
/** True when there is genuinely nothing to draw — say so, don't draw axes. */
|
||||
empty?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Card withBorder radius="md" p="md">
|
||||
<Group justify="space-between" mb="xs" wrap="nowrap">
|
||||
<Text fw={600} size="sm">
|
||||
{title}
|
||||
</Text>
|
||||
{subtitle && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{subtitle}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
{empty ? (
|
||||
<Text size="sm" c="dimmed" py="xl" ta="center">
|
||||
Nothing to show for this filter yet.
|
||||
</Text>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={CHART_HEIGHT}>
|
||||
{children as never}
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A ranked breakdown as horizontal bars.
|
||||
*
|
||||
* Horizontal because the labels are flag states, ports and vessel types —
|
||||
* words, which a vertical axis can show in full instead of rotating them.
|
||||
*/
|
||||
function BreakdownBars({
|
||||
title,
|
||||
subtitle,
|
||||
items,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
items: BreakdownItem[];
|
||||
}) {
|
||||
return (
|
||||
<ChartCard title={title} subtitle={subtitle} empty={items.length === 0}>
|
||||
<BarChart data={items} layout="vertical" margin={{ left: 8, right: 16 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={GRID} horizontal={false} />
|
||||
<XAxis type="number" allowDecimals={false} {...AXIS} />
|
||||
<YAxis type="category" dataKey="label" width={130} {...AXIS} />
|
||||
<Tooltip
|
||||
contentStyle={TOOLTIP_STYLE}
|
||||
formatter={(value, _name, entry) => [
|
||||
countWithShare(value, entry),
|
||||
'Vessels',
|
||||
]}
|
||||
/>
|
||||
<Bar dataKey="count" radius={[0, 4, 4, 0]}>
|
||||
{items.map((item, index) => (
|
||||
<Cell key={item.key} fill={sliceColor(item, index)} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ChartCard>
|
||||
);
|
||||
}
|
||||
|
||||
function BreakdownDonut({
|
||||
title,
|
||||
subtitle,
|
||||
items,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
items: BreakdownItem[];
|
||||
}) {
|
||||
return (
|
||||
<ChartCard title={title} subtitle={subtitle} empty={items.length === 0}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={items}
|
||||
dataKey="count"
|
||||
nameKey="label"
|
||||
innerRadius="52%"
|
||||
outerRadius="78%"
|
||||
paddingAngle={2}
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<Cell key={item.key} fill={sliceColor(item, index)} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={TOOLTIP_STYLE}
|
||||
formatter={(value, name, entry) => [countWithShare(value, entry), name]}
|
||||
/>
|
||||
<Legend
|
||||
verticalAlign="bottom"
|
||||
height={36}
|
||||
wrapperStyle={{ fontSize: 11 }}
|
||||
/>
|
||||
</PieChart>
|
||||
</ChartCard>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* "12 (7.5%)" for a breakdown tooltip.
|
||||
*
|
||||
* The share comes off the payload rather than being recomputed: the API's
|
||||
* percentage is of the whole, including the slices folded into "Other", and
|
||||
* dividing by what is on screen would quietly disagree with it.
|
||||
*/
|
||||
function countWithShare(value: unknown, entry: unknown): string {
|
||||
const count = typeof value === 'number' ? value : Number(value ?? 0);
|
||||
const payload = (entry as { payload?: BreakdownItem } | undefined)?.payload;
|
||||
const share = payload?.percentage ?? 0;
|
||||
return `${formatNumber(count)} (${formatNumber(share, { decimals: 1 })}%)`;
|
||||
}
|
||||
|
||||
/** True when every bucket in a zero-filled series is empty. */
|
||||
const allZero = (values: number[]): boolean =>
|
||||
values.every((value) => value === 0);
|
||||
|
||||
export function ReportCharts({ report }: { report: VesselReport }) {
|
||||
const { timeSeries, breakdowns, kpis } = report;
|
||||
const granularity: ReportGranularity = report.filters.granularity;
|
||||
const tick = (bucket: string) => formatBucket(bucket, granularity);
|
||||
// Recharts types the tooltip label as a ReactNode; only a string is ever a
|
||||
// bucket key, and anything else is passed through untouched.
|
||||
const tickLabel = (label: unknown) =>
|
||||
typeof label === 'string' ? tick(label) : String(label ?? '');
|
||||
|
||||
// Expiry counts arrive cumulative; drawn side by side they have to be
|
||||
// disjoint or the three bars double-count each other.
|
||||
const expiry = expiryBands(kpis.certificates);
|
||||
|
||||
const officers = breakdowns.byOfficer.map((item) => ({
|
||||
...item,
|
||||
label: officerLabel(item.key),
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md">
|
||||
<ChartCard
|
||||
title="Registrations over time"
|
||||
subtitle="count and gross tonnage"
|
||||
empty={allZero(timeSeries.registrations.map((b) => b.count))}
|
||||
>
|
||||
<AreaChart data={timeSeries.registrations}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={GRID} />
|
||||
<XAxis dataKey="bucket" tickFormatter={tick} {...AXIS} />
|
||||
<YAxis yAxisId="count" allowDecimals={false} {...AXIS} />
|
||||
<YAxis yAxisId="tonnage" orientation="right" {...AXIS} />
|
||||
<Tooltip contentStyle={TOOLTIP_STYLE} labelFormatter={tickLabel} />
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
<Area
|
||||
yAxisId="count"
|
||||
type="monotone"
|
||||
dataKey="count"
|
||||
name="Vessels"
|
||||
stroke="var(--mantine-color-blue-6)"
|
||||
fill="var(--mantine-color-blue-2)"
|
||||
/>
|
||||
<Area
|
||||
yAxisId="tonnage"
|
||||
type="monotone"
|
||||
dataKey="grossTonnage"
|
||||
name="Gross tonnage"
|
||||
stroke="var(--mantine-color-teal-6)"
|
||||
fill="transparent"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard
|
||||
title="Application throughput"
|
||||
subtitle="decisions land in the month they were made"
|
||||
empty={allZero(
|
||||
timeSeries.applications.flatMap((b) => [
|
||||
b.submitted,
|
||||
b.approved,
|
||||
b.rejected,
|
||||
]),
|
||||
)}
|
||||
>
|
||||
<BarChart data={timeSeries.applications}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={GRID} />
|
||||
<XAxis dataKey="bucket" tickFormatter={tick} {...AXIS} />
|
||||
<YAxis allowDecimals={false} {...AXIS} />
|
||||
<Tooltip contentStyle={TOOLTIP_STYLE} labelFormatter={tickLabel} />
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
<Bar
|
||||
dataKey="submitted"
|
||||
name="Submitted"
|
||||
fill="var(--mantine-color-blue-4)"
|
||||
/>
|
||||
{/* Approved and rejected stack: together they are the decisions
|
||||
made in that bucket, which reads against intake beside it. */}
|
||||
<Bar
|
||||
dataKey="approved"
|
||||
name="Approved"
|
||||
stackId="decided"
|
||||
fill="var(--mantine-color-teal-6)"
|
||||
/>
|
||||
<Bar
|
||||
dataKey="rejected"
|
||||
name="Rejected"
|
||||
stackId="decided"
|
||||
fill="var(--mantine-color-red-6)"
|
||||
/>
|
||||
</BarChart>
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard
|
||||
title="Fees collected"
|
||||
subtitle={kpis.revenue.currency}
|
||||
empty={allZero(timeSeries.revenue.map((b) => b.amount))}
|
||||
>
|
||||
<LineChart data={timeSeries.revenue}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={GRID} />
|
||||
<XAxis dataKey="bucket" tickFormatter={tick} {...AXIS} />
|
||||
<YAxis {...AXIS} />
|
||||
<Tooltip contentStyle={TOOLTIP_STYLE} labelFormatter={tickLabel} />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="amount"
|
||||
name={`Paid (${kpis.revenue.currency})`}
|
||||
stroke="var(--mantine-color-yellow-7)"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard
|
||||
title="Incidents over time"
|
||||
empty={allZero(timeSeries.incidents.map((b) => b.count))}
|
||||
>
|
||||
<BarChart data={timeSeries.incidents}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={GRID} />
|
||||
<XAxis dataKey="bucket" tickFormatter={tick} {...AXIS} />
|
||||
<YAxis allowDecimals={false} {...AXIS} />
|
||||
<Tooltip contentStyle={TOOLTIP_STYLE} labelFormatter={tickLabel} />
|
||||
<Bar
|
||||
dataKey="count"
|
||||
name="Incidents"
|
||||
fill="var(--mantine-color-orange-6)"
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ChartCard>
|
||||
</SimpleGrid>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md" mt="md">
|
||||
<BreakdownDonut title="Register status" items={breakdowns.byStatus} />
|
||||
<BreakdownDonut title="Category" items={breakdowns.byCategory} />
|
||||
<ChartCard
|
||||
title="Certificate expiry"
|
||||
subtitle="disjoint bands"
|
||||
empty={allZero(expiry.map((band) => band.count))}
|
||||
>
|
||||
<BarChart data={expiry} layout="vertical" margin={{ left: 8, right: 16 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={GRID} horizontal={false} />
|
||||
<XAxis type="number" allowDecimals={false} {...AXIS} />
|
||||
<YAxis type="category" dataKey="label" width={110} {...AXIS} />
|
||||
<Tooltip contentStyle={TOOLTIP_STYLE} />
|
||||
<Bar
|
||||
dataKey="count"
|
||||
name="Certificates"
|
||||
fill="var(--mantine-color-orange-6)"
|
||||
radius={[0, 4, 4, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ChartCard>
|
||||
|
||||
<BreakdownBars title="Tonnage bands" items={breakdowns.byTonnageBand} />
|
||||
<BreakdownBars title="Age bands" items={breakdowns.byAgeBand} />
|
||||
<BreakdownBars title="Length bands" items={breakdowns.byLengthBand} />
|
||||
|
||||
<BreakdownBars
|
||||
title="Flag states"
|
||||
subtitle="top slices, rest grouped"
|
||||
items={breakdowns.byFlagState}
|
||||
/>
|
||||
<BreakdownBars
|
||||
title="Ports of registry"
|
||||
subtitle="top slices, rest grouped"
|
||||
items={breakdowns.byPortOfRegistry}
|
||||
/>
|
||||
<BreakdownBars title="Vessel types" items={breakdowns.byVesselType} />
|
||||
|
||||
<BreakdownBars title="Hull material" items={breakdowns.byHullMaterial} />
|
||||
<BreakdownBars title="Engine type" items={breakdowns.byEngineType} />
|
||||
<BreakdownBars title="Build decade" items={breakdowns.byBuildDecade} />
|
||||
|
||||
<BreakdownBars
|
||||
title="Application status"
|
||||
items={breakdowns.byApplicationStatus}
|
||||
/>
|
||||
<BreakdownDonut
|
||||
title="New vs renewal"
|
||||
items={breakdowns.byApplicationKind}
|
||||
/>
|
||||
<BreakdownDonut
|
||||
title="Incident severity"
|
||||
subtitle="free text on the register"
|
||||
items={breakdowns.byIncidentSeverity}
|
||||
/>
|
||||
|
||||
<BreakdownBars
|
||||
title="Officer workload"
|
||||
subtitle="user id — names not resolved"
|
||||
items={officers}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
MultiSelect,
|
||||
SegmentedControl,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
import { DatePickerInput } from '@mantine/dates';
|
||||
import { IconDownload, IconSearch, IconX } from '@tabler/icons-react';
|
||||
import type {
|
||||
ReportGranularity,
|
||||
VesselCategory,
|
||||
VesselReport,
|
||||
VesselReportQuery,
|
||||
VesselStatus,
|
||||
} from '@ema-platform/api';
|
||||
import { optionsFrom } from './report-format';
|
||||
|
||||
const CATEGORY_OPTIONS = [
|
||||
{ value: 'SEA_GOING', label: 'Sea-going' },
|
||||
{ value: 'INLAND_WATERWAY', label: 'Inland waterway' },
|
||||
];
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 'REGISTERED', label: 'Registered' },
|
||||
{ value: 'SUSPENDED', label: 'Suspended' },
|
||||
{ value: 'DEREGISTERED', label: 'Deregistered' },
|
||||
];
|
||||
|
||||
const GRANULARITY_OPTIONS = [
|
||||
{ value: 'DAY', label: 'Day' },
|
||||
{ value: 'WEEK', label: 'Week' },
|
||||
{ value: 'MONTH', label: 'Month' },
|
||||
];
|
||||
|
||||
interface ReportFiltersProps {
|
||||
query: VesselReportQuery;
|
||||
onChange: (next: VesselReportQuery) => void;
|
||||
/**
|
||||
* The last successful response. Flag states, ports and vessel types are free
|
||||
* text on the register with no lookup endpoint behind them, so the only
|
||||
* honest source for the options is what the register actually holds.
|
||||
*/
|
||||
report?: VesselReport;
|
||||
onExport: () => void;
|
||||
exporting: boolean;
|
||||
}
|
||||
|
||||
export function ReportFilters({
|
||||
query,
|
||||
onChange,
|
||||
report,
|
||||
onExport,
|
||||
exporting,
|
||||
}: ReportFiltersProps) {
|
||||
// The search box is local so typing does not refetch on every keystroke; it
|
||||
// is pushed up on a debounce.
|
||||
const [search, setSearch] = useState(query.search ?? '');
|
||||
|
||||
useEffect(() => {
|
||||
setSearch(query.search ?? '');
|
||||
}, [query.search]);
|
||||
|
||||
useEffect(() => {
|
||||
const current = query.search ?? '';
|
||||
if (search === current) return;
|
||||
const timer = setTimeout(
|
||||
() => onChange({ ...query, search: search.trim() || undefined }),
|
||||
350,
|
||||
);
|
||||
return () => clearTimeout(timer);
|
||||
}, [search, query, onChange]);
|
||||
|
||||
const set = <K extends keyof VesselReportQuery>(
|
||||
key: K,
|
||||
value: VesselReportQuery[K],
|
||||
) => onChange({ ...query, [key]: value });
|
||||
|
||||
// Mantine 8 works in `YYYY-MM-DD` strings here, which is exactly what the
|
||||
// API wants — no Date round trip, and no timezone to shift the day.
|
||||
const range: [string | null, string | null] = [
|
||||
query.from ?? null,
|
||||
query.to ?? null,
|
||||
];
|
||||
|
||||
const filtered =
|
||||
Boolean(query.search) ||
|
||||
Boolean(query.from) ||
|
||||
Boolean(query.to) ||
|
||||
[
|
||||
query.category,
|
||||
query.status,
|
||||
query.flagState,
|
||||
query.portOfRegistry,
|
||||
query.vesselType,
|
||||
].some((values) => (values ?? []).length > 0);
|
||||
|
||||
return (
|
||||
<Card withBorder radius="md" p="md" mb="md">
|
||||
<Group align="flex-end" gap="sm" wrap="wrap">
|
||||
<DatePickerInput
|
||||
type="range"
|
||||
label="Period"
|
||||
placeholder="Last 12 months"
|
||||
value={range}
|
||||
// Both ends before refetching: a half-set range would send `from`
|
||||
// with no `to` and redraw the charts against a window the user is
|
||||
// still in the middle of choosing.
|
||||
onChange={([from, to]) => {
|
||||
if (from && !to) return;
|
||||
onChange({
|
||||
...query,
|
||||
from: from ?? undefined,
|
||||
to: to ?? undefined,
|
||||
});
|
||||
}}
|
||||
clearable
|
||||
w={250}
|
||||
/>
|
||||
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
data={GRANULARITY_OPTIONS}
|
||||
value={query.granularity ?? 'MONTH'}
|
||||
onChange={(value) => set('granularity', value as ReportGranularity)}
|
||||
/>
|
||||
|
||||
<MultiSelect
|
||||
label="Category"
|
||||
placeholder="All"
|
||||
data={CATEGORY_OPTIONS}
|
||||
value={query.category ?? []}
|
||||
onChange={(value) => set('category', value as VesselCategory[])}
|
||||
clearable
|
||||
w={190}
|
||||
/>
|
||||
|
||||
<MultiSelect
|
||||
label="Status"
|
||||
placeholder="All"
|
||||
data={STATUS_OPTIONS}
|
||||
value={query.status ?? []}
|
||||
onChange={(value) => set('status', value as VesselStatus[])}
|
||||
clearable
|
||||
w={190}
|
||||
/>
|
||||
|
||||
<MultiSelect
|
||||
label="Flag state"
|
||||
placeholder="All"
|
||||
data={optionsFrom(report?.breakdowns.byFlagState)}
|
||||
value={query.flagState ?? []}
|
||||
onChange={(value) => set('flagState', value)}
|
||||
searchable
|
||||
clearable
|
||||
w={190}
|
||||
/>
|
||||
|
||||
<MultiSelect
|
||||
label="Port of registry"
|
||||
placeholder="All"
|
||||
data={optionsFrom(report?.breakdowns.byPortOfRegistry)}
|
||||
value={query.portOfRegistry ?? []}
|
||||
onChange={(value) => set('portOfRegistry', value)}
|
||||
searchable
|
||||
clearable
|
||||
w={190}
|
||||
/>
|
||||
|
||||
<MultiSelect
|
||||
label="Vessel type"
|
||||
placeholder="All"
|
||||
data={optionsFrom(report?.breakdowns.byVesselType)}
|
||||
value={query.vesselType ?? []}
|
||||
onChange={(value) => set('vesselType', value)}
|
||||
searchable
|
||||
clearable
|
||||
w={190}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Search"
|
||||
placeholder="Name, register №, IMO or owner"
|
||||
leftSection={<IconSearch size={14} />}
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.currentTarget.value)}
|
||||
w={250}
|
||||
/>
|
||||
|
||||
<Group gap="xs" ml="auto">
|
||||
{filtered && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
leftSection={<IconX size={14} />}
|
||||
onClick={() => onChange({})}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<IconDownload size={16} />}
|
||||
loading={exporting}
|
||||
onClick={onExport}
|
||||
>
|
||||
Export CSV
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Badge, Card, Group, SimpleGrid, Table, Text } from '@mantine/core';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { VesselReport } from '@ema-platform/api';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import { expiryUrgency, formatNumber } from './report-format';
|
||||
|
||||
/**
|
||||
* The worklists.
|
||||
*
|
||||
* Plain Mantine tables rather than `AdvancedTable`: every one of these is
|
||||
* already capped server-side by `tableLimit`, so the pagination, search and
|
||||
* column-picker that component brings would all be controls over a list that
|
||||
* is only ever ten rows of a much longer one. Each card links out to the screen
|
||||
* that does own the full list.
|
||||
*/
|
||||
function TableCard({
|
||||
title,
|
||||
subtitle,
|
||||
to,
|
||||
linkLabel,
|
||||
empty,
|
||||
head,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
to?: string;
|
||||
linkLabel?: string;
|
||||
empty: boolean;
|
||||
head: string[];
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Card withBorder radius="md" p="md">
|
||||
<Group justify="space-between" mb="xs" wrap="nowrap">
|
||||
<div>
|
||||
<Text fw={600} size="sm">
|
||||
{title}
|
||||
</Text>
|
||||
{subtitle && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{subtitle}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
{to && (
|
||||
<Text component={Link} to={to} size="xs" c="blue">
|
||||
{linkLabel ?? 'View all'}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
{empty ? (
|
||||
<Text size="sm" c="dimmed" py="lg" ta="center">
|
||||
Nothing to show.
|
||||
</Text>
|
||||
) : (
|
||||
<Table highlightOnHover verticalSpacing="xs" fz="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
{head.map((column) => (
|
||||
<Table.Th key={column}>{column}</Table.Th>
|
||||
))}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>{children}</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReportTables({ report }: { report: VesselReport }) {
|
||||
const showDate = useDateDisplayer();
|
||||
const { tables, filters } = report;
|
||||
|
||||
return (
|
||||
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md" mt="md">
|
||||
<TableCard
|
||||
title="Certificates expiring"
|
||||
subtitle={`within ${filters.expiringWithinDays} days`}
|
||||
to="/licence-register"
|
||||
empty={tables.expiringCertificates.length === 0}
|
||||
head={['Vessel', 'Certificate', 'Expires', 'Days']}
|
||||
>
|
||||
{tables.expiringCertificates.map((row) => (
|
||||
<Table.Tr key={row.vesselId}>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={500}>
|
||||
{row.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{row.registrationNumber}
|
||||
{row.ownerName ? ` · ${row.ownerName}` : ''}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{row.certificateNumber ?? '—'}</Table.Td>
|
||||
<Table.Td>{showDate(row.expiryDate)}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={expiryUrgency(row.daysToExpiry)}
|
||||
>
|
||||
{/* 0 is today, and a certificate is valid through its last day. */}
|
||||
{row.daysToExpiry === 0
|
||||
? 'Today'
|
||||
: `${formatNumber(row.daysToExpiry)} d`}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</TableCard>
|
||||
|
||||
<TableCard
|
||||
title="Recent registrations"
|
||||
to="/vessel-registration-queue"
|
||||
linkLabel="Open register"
|
||||
empty={tables.recentRegistrations.length === 0}
|
||||
head={['Vessel', 'Category', 'Flag', 'Registered']}
|
||||
>
|
||||
{tables.recentRegistrations.map((row) => (
|
||||
<Table.Tr key={row.vesselId}>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={500}>
|
||||
{row.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{row.registrationNumber}
|
||||
{row.vesselType ? ` · ${row.vesselType}` : ''}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{row.category === 'SEA_GOING' ? 'Sea-going' : 'Inland'}
|
||||
</Table.Td>
|
||||
<Table.Td>{row.flagState ?? '—'}</Table.Td>
|
||||
<Table.Td>{showDate(row.registeredAt)}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</TableCard>
|
||||
|
||||
<TableCard
|
||||
title="Applications in the queue"
|
||||
subtitle="oldest first"
|
||||
to="/licence-review/type/VESSEL_REGISTRATION"
|
||||
linkLabel="Open queue"
|
||||
empty={tables.pendingApplications.length === 0}
|
||||
head={['Application', 'Status', 'Submitted', 'Open']}
|
||||
>
|
||||
{tables.pendingApplications.map((row) => (
|
||||
<Table.Tr key={row.applicationNumber}>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={500}>
|
||||
{row.applicationNumber}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{row.kind === 'RENEWAL' ? 'Renewal' : 'New'}
|
||||
{row.adjustmentRound > 0
|
||||
? ` · ${row.adjustmentRound} adjustment round${row.adjustmentRound === 1 ? '' : 's'}`
|
||||
: ''}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" variant="light">
|
||||
{row.status.replaceAll('_', ' ')}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{row.submittedAt ? showDate(row.submittedAt) : '—'}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={row.daysOpen > 30 ? 'red' : row.daysOpen > 14 ? 'orange' : 'gray'}
|
||||
>
|
||||
{formatNumber(row.daysOpen)} d
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</TableCard>
|
||||
|
||||
<TableCard
|
||||
title="Recent incidents"
|
||||
empty={tables.recentIncidents.length === 0}
|
||||
head={['Vessel', 'Occurred', 'Severity', 'Reported by']}
|
||||
>
|
||||
{tables.recentIncidents.map((row) => (
|
||||
<Table.Tr key={row.id}>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={500}>
|
||||
{row.vesselName}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{row.description}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{showDate(row.occurredAt)}</Table.Td>
|
||||
<Table.Td>{row.severity ?? '—'}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" variant="light" color={row.reportedByOfficer ? 'blue' : 'gray'}>
|
||||
{row.reportedByOfficer ? 'Officer' : 'Owner'}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</TableCard>
|
||||
</SimpleGrid>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Alert, Container, Group, Text, Title } from '@mantine/core';
|
||||
import { IconAlertTriangle, IconShip } from '@tabler/icons-react';
|
||||
import {
|
||||
ApiErrorAlert,
|
||||
EmptyState,
|
||||
PageLoader,
|
||||
notify,
|
||||
} from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import {
|
||||
downloadAuthedFile,
|
||||
extractErrorMessage,
|
||||
useGetVesselReportQuery,
|
||||
} from '@ema-platform/api';
|
||||
import type { VesselReportQuery } from '@ema-platform/api';
|
||||
import { KpiTiles } from './KpiTiles';
|
||||
import { ReportCharts } from './ReportCharts';
|
||||
import { ReportFilters } from './ReportFilters';
|
||||
import { ReportTables } from './ReportTables';
|
||||
import { queryToSearchParams, searchParamsToQuery } from './report-format';
|
||||
|
||||
/**
|
||||
* The vessel registration dashboard (module 11).
|
||||
*
|
||||
* One `GET /vessels/report` call fills the whole screen — KPIs, four time
|
||||
* series, fifteen breakdowns and four worklists — so the filter bar drives a
|
||||
* single refetch rather than a dozen independent ones.
|
||||
*
|
||||
* Filter state lives in the URL. A filtered dashboard is the thing an officer
|
||||
* wants to send someone, and rebuilding six selects from a description is not
|
||||
* how that conversation should go.
|
||||
*/
|
||||
export function VesselRegistrationReportPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const showDate = useDateDisplayer();
|
||||
|
||||
const query: VesselReportQuery = useMemo(
|
||||
() => searchParamsToQuery(searchParams),
|
||||
[searchParams],
|
||||
);
|
||||
|
||||
const setQuery = useCallback(
|
||||
(next: VesselReportQuery) => {
|
||||
// `replace` so a session of narrowing filters does not bury the page the
|
||||
// officer arrived from under twenty history entries.
|
||||
setSearchParams(queryToSearchParams(next), { replace: true });
|
||||
},
|
||||
[setSearchParams],
|
||||
);
|
||||
|
||||
const { data: report, isLoading, isFetching, error } = useGetVesselReportQuery(
|
||||
query,
|
||||
);
|
||||
|
||||
const exportCsv = useCallback(async () => {
|
||||
setExporting(true);
|
||||
try {
|
||||
const params = queryToSearchParams(query).toString();
|
||||
const { rowCount, truncated } = await downloadAuthedFile(
|
||||
`/vessels/report/export${params ? `?${params}` : ''}`,
|
||||
'vessel-register.csv',
|
||||
);
|
||||
if (truncated) {
|
||||
notify.error(
|
||||
`Export cut off at ${rowCount ?? 'the row limit'} rows. Narrow the filter and export again.`,
|
||||
);
|
||||
} else {
|
||||
notify.success(
|
||||
`Exported ${rowCount ?? 'the filtered'} vessel${rowCount === 1 ? '' : 's'}.`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
notify.error(extractErrorMessage(err, 'Could not export the register'));
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
}, [query]);
|
||||
|
||||
// Only the very first load blanks the page; a filter change keeps the last
|
||||
// report on screen so the dashboard does not flash between every tweak.
|
||||
if (isLoading) return <PageLoader />;
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md">
|
||||
<Group justify="space-between" mb="md" align="flex-start">
|
||||
<div>
|
||||
<Title order={3}>Vessel registration report</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{report
|
||||
? `Register-wide totals with a ${showDate(report.filters.from)} – ${showDate(report.filters.to)} window on the trends.`
|
||||
: 'The national vessel register at a glance.'}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<ReportFilters
|
||||
query={query}
|
||||
onChange={setQuery}
|
||||
report={report}
|
||||
onExport={exportCsv}
|
||||
exporting={exporting}
|
||||
/>
|
||||
|
||||
{error && <ApiErrorAlert error={error} title="Could not load the report" />}
|
||||
|
||||
{report && (
|
||||
<>
|
||||
{report.truncated && (
|
||||
<Alert
|
||||
color="yellow"
|
||||
icon={<IconAlertTriangle size={16} />}
|
||||
mb="md"
|
||||
title="Partial figures"
|
||||
>
|
||||
The register is larger than this report can scan in one pass, so
|
||||
every figure below covers only part of it. Narrow the filter for
|
||||
an exact answer.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{report.kpis.register.total === 0 ? (
|
||||
<EmptyState
|
||||
icon={IconShip}
|
||||
title="No vessels match this filter"
|
||||
description={
|
||||
Object.keys(query).length > 0
|
||||
? 'Nothing on the register matches the current filter. Clear it to see the whole book.'
|
||||
: 'No vessels have been registered yet. Entries appear here once a registration certificate is issued.'
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ opacity: isFetching ? 0.6 : 1, transition: 'opacity 120ms' }}>
|
||||
<KpiTiles report={report} />
|
||||
<div style={{ marginTop: 'var(--mantine-spacing-md)' }}>
|
||||
<ReportCharts report={report} />
|
||||
</div>
|
||||
<ReportTables report={report} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default VesselRegistrationReportPage;
|
||||
@@ -0,0 +1,187 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { BreakdownItem, CertificateKpis } from '@ema-platform/api';
|
||||
import {
|
||||
DASH,
|
||||
defaultRange,
|
||||
deltaColor,
|
||||
expiryBands,
|
||||
expiryUrgency,
|
||||
formatBucket,
|
||||
formatDelta,
|
||||
formatNumber,
|
||||
formatPercent,
|
||||
officerLabel,
|
||||
optionsFrom,
|
||||
queryToSearchParams,
|
||||
searchParamsToQuery,
|
||||
sliceColor,
|
||||
} from './report-format';
|
||||
|
||||
const certificates = (partial: Partial<CertificateKpis>): CertificateKpis => ({
|
||||
total: 0,
|
||||
active: 0,
|
||||
expired: 0,
|
||||
suspended: 0,
|
||||
expiringIn30: 0,
|
||||
expiringIn60: 0,
|
||||
expiringIn90: 0,
|
||||
missingCertificate: 0,
|
||||
...partial,
|
||||
});
|
||||
|
||||
const item = (key: string, count = 1): BreakdownItem => ({
|
||||
key,
|
||||
label: key,
|
||||
count,
|
||||
percentage: 0,
|
||||
});
|
||||
|
||||
describe('formatNumber', () => {
|
||||
it('renders a dash for a figure the API had no answer for', () => {
|
||||
expect(formatNumber(null)).toBe(DASH);
|
||||
expect(formatNumber(undefined)).toBe(DASH);
|
||||
expect(formatNumber(Number.NaN)).toBe(DASH);
|
||||
});
|
||||
|
||||
it('keeps a real zero', () => {
|
||||
expect(formatNumber(0)).toBe('0');
|
||||
});
|
||||
|
||||
it('honours decimals and a suffix', () => {
|
||||
expect(formatNumber(12.345, { decimals: 2 })).toBe('12.35');
|
||||
expect(formatNumber(7, { suffix: ' GT' })).toBe('7 GT');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatPercent / formatDelta', () => {
|
||||
it('distinguishes no answer from zero', () => {
|
||||
expect(formatPercent(null)).toBe(DASH);
|
||||
expect(formatPercent(0)).toBe('0.0%');
|
||||
expect(formatDelta(null)).toBe(DASH);
|
||||
});
|
||||
|
||||
it('signs a positive change', () => {
|
||||
expect(formatDelta(12.5)).toBe('+12.5%');
|
||||
expect(formatDelta(-4)).toBe('-4.0%');
|
||||
});
|
||||
|
||||
it('colours a flat or absent change neutrally', () => {
|
||||
expect(deltaColor(null)).toBe('gray');
|
||||
expect(deltaColor(0)).toBe('gray');
|
||||
expect(deltaColor(1)).toBe('teal');
|
||||
expect(deltaColor(-1)).toBe('red');
|
||||
});
|
||||
});
|
||||
|
||||
describe('expiryBands', () => {
|
||||
it('differences the API cumulative counts into disjoint bands', () => {
|
||||
expect(
|
||||
expiryBands(
|
||||
certificates({ expiringIn30: 4, expiringIn60: 9, expiringIn90: 11 }),
|
||||
),
|
||||
).toEqual([
|
||||
{ label: 'Within 30 days', count: 4 },
|
||||
{ label: '31–60 days', count: 5 },
|
||||
{ label: '61–90 days', count: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('never draws a negative bar if the counts are not monotonic', () => {
|
||||
const bands = expiryBands(
|
||||
certificates({ expiringIn30: 9, expiringIn60: 4, expiringIn90: 4 }),
|
||||
);
|
||||
expect(bands.every((band) => band.count >= 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('expiryUrgency', () => {
|
||||
it('escalates on the boundaries', () => {
|
||||
expect(expiryUrgency(0)).toBe('red');
|
||||
expect(expiryUrgency(7)).toBe('red');
|
||||
expect(expiryUrgency(8)).toBe('orange');
|
||||
expect(expiryUrgency(30)).toBe('orange');
|
||||
expect(expiryUrgency(31)).toBe('gray');
|
||||
});
|
||||
});
|
||||
|
||||
describe('officerLabel', () => {
|
||||
it('spells out the unassigned bucket and shortens a uuid', () => {
|
||||
expect(officerLabel('UNASSIGNED')).toBe('Unassigned');
|
||||
expect(officerLabel('c8d0a151-91e9-433e-b221-db331480b10f')).toBe(
|
||||
'c8d0a151…',
|
||||
);
|
||||
expect(officerLabel('short')).toBe('short');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sliceColor', () => {
|
||||
it('mutes the bookkeeping slices and cycles the rest', () => {
|
||||
const muted = sliceColor(item('OTHER'), 0);
|
||||
expect(sliceColor(item('Unknown'), 3)).toBe(muted);
|
||||
expect(sliceColor(item('SEA_GOING'), 0)).not.toBe(muted);
|
||||
});
|
||||
|
||||
it('is stable for a given position', () => {
|
||||
expect(sliceColor(item('A'), 2)).toBe(sliceColor(item('B'), 2));
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatBucket', () => {
|
||||
it('reads a month bucket as a month and a day bucket as a day', () => {
|
||||
expect(formatBucket('2026-03-01', 'MONTH')).toMatch(/2026/);
|
||||
expect(formatBucket('2026-03-04', 'DAY')).not.toMatch(/2026/);
|
||||
});
|
||||
|
||||
it('passes an unparseable bucket through rather than printing NaN', () => {
|
||||
expect(formatBucket('not-a-date', 'MONTH')).toBe('not-a-date');
|
||||
});
|
||||
});
|
||||
|
||||
describe('defaultRange', () => {
|
||||
it('spans the twelve months the API defaults to', () => {
|
||||
const [from, to] = defaultRange(new Date('2026-08-18T00:00:00Z'));
|
||||
expect(from.toISOString().slice(0, 10)).toBe('2025-08-18');
|
||||
expect(to.toISOString().slice(0, 10)).toBe('2026-08-18');
|
||||
});
|
||||
});
|
||||
|
||||
describe('url round trip', () => {
|
||||
it('drops empty values so an untouched dashboard has a clean link', () => {
|
||||
const params = queryToSearchParams({
|
||||
search: '',
|
||||
category: [],
|
||||
topN: 15,
|
||||
});
|
||||
expect(params.toString()).toBe('topN=15');
|
||||
});
|
||||
|
||||
it('restores the filter state a shared link carries', () => {
|
||||
const query = {
|
||||
from: '2026-01-01',
|
||||
to: '2026-08-18',
|
||||
granularity: 'WEEK' as const,
|
||||
status: ['REGISTERED' as const, 'SUSPENDED' as const],
|
||||
flagState: ['Ethiopia'],
|
||||
search: 'abay',
|
||||
topN: 20,
|
||||
};
|
||||
expect(searchParamsToQuery(queryToSearchParams(query))).toEqual(query);
|
||||
});
|
||||
|
||||
it('ignores a hand-edited value the API would reject', () => {
|
||||
const query = searchParamsToQuery(
|
||||
new URLSearchParams('topN=abc&granularity=YEAR'),
|
||||
);
|
||||
expect(query.topN).toBeUndefined();
|
||||
expect(query.granularity).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('optionsFrom', () => {
|
||||
it('offers the register values but not the bookkeeping slices', () => {
|
||||
expect(
|
||||
optionsFrom([item('Ethiopia'), item('Unknown'), item('OTHER')]),
|
||||
).toEqual(['Ethiopia']);
|
||||
expect(optionsFrom(undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
import type {
|
||||
BreakdownItem,
|
||||
CertificateKpis,
|
||||
ReportGranularity,
|
||||
VesselReportQuery,
|
||||
} from '@ema-platform/api';
|
||||
|
||||
/** Nothing measurable is not zero — an em dash says so without lying. */
|
||||
export const DASH = '—';
|
||||
|
||||
/**
|
||||
* A figure the API may legitimately have no answer for.
|
||||
*
|
||||
* `avgGrossTonnage` is null on an empty register and `approvalRatePct` is null
|
||||
* until something has been decided; rendering either as 0 would report a fleet
|
||||
* that weighs nothing and a service that approves nobody.
|
||||
*/
|
||||
export function formatNumber(
|
||||
value: number | null | undefined,
|
||||
options: { decimals?: number; suffix?: string } = {},
|
||||
): string {
|
||||
if (value === null || value === undefined || Number.isNaN(value)) return DASH;
|
||||
const text = value.toLocaleString(undefined, {
|
||||
minimumFractionDigits: options.decimals ?? 0,
|
||||
maximumFractionDigits: options.decimals ?? 0,
|
||||
});
|
||||
return options.suffix ? `${text}${options.suffix}` : text;
|
||||
}
|
||||
|
||||
export function formatPercent(value: number | null | undefined): string {
|
||||
return value === null || value === undefined
|
||||
? DASH
|
||||
: `${formatNumber(value, { decimals: 1 })}%`;
|
||||
}
|
||||
|
||||
export function formatMoney(value: number, currency: string): string {
|
||||
return `${formatNumber(value, { decimals: 2 })} ${currency}`;
|
||||
}
|
||||
|
||||
/** A signed delta for the change-vs-previous chip. */
|
||||
export function formatDelta(value: number | null): string {
|
||||
if (value === null) return DASH;
|
||||
const sign = value > 0 ? '+' : '';
|
||||
return `${sign}${formatNumber(value, { decimals: 1 })}%`;
|
||||
}
|
||||
|
||||
export function deltaColor(value: number | null): string {
|
||||
if (value === null || value === 0) return 'gray';
|
||||
return value > 0 ? 'teal' : 'red';
|
||||
}
|
||||
|
||||
/**
|
||||
* The API's expiry counts are cumulative — a certificate due in eleven days is
|
||||
* inside the 30-, 60- and 90-day figures, which is how a renewals desk reads
|
||||
* them. Stacked side by side in a chart that reads as three separate groups,
|
||||
* so they are differenced into disjoint bands first.
|
||||
*/
|
||||
export function expiryBands(
|
||||
certificates: CertificateKpis,
|
||||
): Array<{ label: string; count: number }> {
|
||||
const { expiringIn30, expiringIn60, expiringIn90 } = certificates;
|
||||
return [
|
||||
{ label: 'Within 30 days', count: expiringIn30 },
|
||||
// Math.max guards against a server that ever answers non-monotonically —
|
||||
// a negative bar is worse than a zero one.
|
||||
{ label: '31–60 days', count: Math.max(0, expiringIn60 - expiringIn30) },
|
||||
{ label: '61–90 days', count: Math.max(0, expiringIn90 - expiringIn60) },
|
||||
];
|
||||
}
|
||||
|
||||
/** Red inside a week, orange inside a month, otherwise unremarkable. */
|
||||
export function expiryUrgency(daysToExpiry: number): string {
|
||||
if (daysToExpiry <= 7) return 'red';
|
||||
if (daysToExpiry <= 30) return 'orange';
|
||||
return 'gray';
|
||||
}
|
||||
|
||||
/**
|
||||
* Officer ids are IAM uuids, which make useless axis labels. Until the
|
||||
* dashboard has a name lookup, shorten them and keep "UNASSIGNED" readable.
|
||||
*/
|
||||
export function officerLabel(key: string): string {
|
||||
if (key === 'UNASSIGNED') return 'Unassigned';
|
||||
return key.length > 8 ? `${key.slice(0, 8)}…` : key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chart colours, assigned by position so a slice keeps its colour between
|
||||
* renders. Mantine's palette rather than invented hex codes, so the charts
|
||||
* follow the theme the rest of the app is built on.
|
||||
*/
|
||||
const PALETTE = [
|
||||
'var(--mantine-color-blue-6)',
|
||||
'var(--mantine-color-teal-6)',
|
||||
'var(--mantine-color-orange-6)',
|
||||
'var(--mantine-color-grape-6)',
|
||||
'var(--mantine-color-cyan-6)',
|
||||
'var(--mantine-color-lime-7)',
|
||||
'var(--mantine-color-pink-6)',
|
||||
'var(--mantine-color-indigo-6)',
|
||||
];
|
||||
|
||||
const MUTED = 'var(--mantine-color-gray-5)';
|
||||
|
||||
/**
|
||||
* "Unknown" and "Other" are bookkeeping slices rather than findings, so they
|
||||
* always take the muted colour instead of competing with the real categories
|
||||
* for one of the bright ones.
|
||||
*/
|
||||
export function sliceColor(item: BreakdownItem, index: number): string {
|
||||
if (item.key === 'OTHER' || item.key === 'Unknown') return MUTED;
|
||||
return PALETTE[index % PALETTE.length];
|
||||
}
|
||||
|
||||
/** Bucket keys are ISO dates; the axis wants something a human reads. */
|
||||
export function formatBucket(
|
||||
bucket: string,
|
||||
granularity: ReportGranularity,
|
||||
): string {
|
||||
const date = new Date(bucket);
|
||||
if (Number.isNaN(date.getTime())) return bucket;
|
||||
if (granularity === 'MONTH') {
|
||||
return date.toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
}
|
||||
return date.toLocaleDateString(undefined, {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
}
|
||||
|
||||
/** The default window the API applies when none is given: the last 12 months. */
|
||||
export function defaultRange(now: Date): [Date, Date] {
|
||||
const from = new Date(
|
||||
Date.UTC(now.getUTCFullYear() - 1, now.getUTCMonth(), now.getUTCDate()),
|
||||
);
|
||||
return [from, now];
|
||||
}
|
||||
|
||||
export const ISO_DAY_LENGTH = 10;
|
||||
|
||||
export const toIsoDay = (date: Date): string =>
|
||||
date.toISOString().slice(0, ISO_DAY_LENGTH);
|
||||
|
||||
/**
|
||||
* The filter state as URL search params, so a filtered dashboard is a
|
||||
* shareable link rather than something the next person has to rebuild.
|
||||
*
|
||||
* Empty arrays and blank strings are dropped rather than serialised, which
|
||||
* keeps an untouched dashboard's URL clean and lets the API apply its own
|
||||
* defaults instead of being handed an empty filter to honour.
|
||||
*/
|
||||
export function queryToSearchParams(query: VesselReportQuery): URLSearchParams {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value === undefined || value === null || value === '') continue;
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) continue;
|
||||
params.set(key, value.join(','));
|
||||
} else {
|
||||
params.set(key, String(value));
|
||||
}
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
const ARRAY_KEYS = [
|
||||
'category',
|
||||
'status',
|
||||
'flagState',
|
||||
'portOfRegistry',
|
||||
'vesselType',
|
||||
] as const;
|
||||
|
||||
const NUMBER_KEYS = ['expiringWithinDays', 'topN', 'tableLimit'] as const;
|
||||
|
||||
/** The inverse, for restoring state from a shared link. */
|
||||
export function searchParamsToQuery(
|
||||
params: URLSearchParams,
|
||||
): VesselReportQuery {
|
||||
const query: Record<string, unknown> = {};
|
||||
for (const key of ARRAY_KEYS) {
|
||||
const raw = params.get(key);
|
||||
if (raw) query[key] = raw.split(',').filter(Boolean);
|
||||
}
|
||||
for (const key of NUMBER_KEYS) {
|
||||
const raw = params.get(key);
|
||||
// An unparseable number in a hand-edited URL is ignored rather than sent
|
||||
// on to fail the API's validation pipe.
|
||||
if (raw !== null && raw !== '' && Number.isFinite(Number(raw))) {
|
||||
query[key] = Number(raw);
|
||||
}
|
||||
}
|
||||
for (const key of ['from', 'to', 'search'] as const) {
|
||||
const raw = params.get(key);
|
||||
if (raw) query[key] = raw;
|
||||
}
|
||||
const granularity = params.get('granularity');
|
||||
if (granularity === 'DAY' || granularity === 'WEEK' || granularity === 'MONTH') {
|
||||
query.granularity = granularity;
|
||||
}
|
||||
return query as VesselReportQuery;
|
||||
}
|
||||
|
||||
/**
|
||||
* The multi-select options a filter offers, taken from the breakdown the last
|
||||
* response carried — there is no lookup endpoint for flag states or ports, and
|
||||
* the register is the only place that knows which ones are in use.
|
||||
*
|
||||
* "Unknown" is dropped: it stands for a missing value, and there is nothing to
|
||||
* filter the register down to.
|
||||
*/
|
||||
export function optionsFrom(items: BreakdownItem[] | undefined): string[] {
|
||||
return (items ?? [])
|
||||
.filter((item) => item.key !== 'Unknown' && item.key !== 'OTHER')
|
||||
.map((item) => item.key);
|
||||
}
|
||||
@@ -61,7 +61,6 @@ export const am: Translations = {
|
||||
soon: "በቅርቡ",
|
||||
details: "ዝርዝር",
|
||||
licenceReview: "የፈቃድ ማመልከቻዎች",
|
||||
vesselRegistrationHeadDashboard: "የመርከብ ምዝገባ ኃላፊ ዳሽቦርድ",
|
||||
vesselRegistrationApplicationQueue: "የመርከብ ምዝገባ ወረፋ",
|
||||
vesselRegistrationQueue: "የመርከብ መዝገብ",
|
||||
vesselFormBuilder: "የመርከብ ቅጽ መገንቢያ",
|
||||
@@ -107,6 +106,8 @@ export const am: Translations = {
|
||||
|
||||
common: {
|
||||
logout: "ውጣ",
|
||||
idleLogoutTitle: "ክፍለ ጊዜው አልቋል",
|
||||
idleLogoutMessage: "ለ15 ደቂቃ እንቅስቃሴ ባለማድረግዎ ምክንያት ወጥተዋል።",
|
||||
profile: "መገለጫ",
|
||||
settings: "ቅንብሮች",
|
||||
export: "ላክ",
|
||||
@@ -454,9 +455,41 @@ export const am: Translations = {
|
||||
dark: "ሌሊት",
|
||||
system: "ሲስተም",
|
||||
},
|
||||
sessions: {
|
||||
title: 'ንቁ የመግቢያ ክፍለ ጊዜዎች',
|
||||
hint: 'በአሁኑ ሰዓት ወደ መለያዎ የገቡ መሣሪያዎች። የማያውቁትን ይሰርዙ።',
|
||||
columns: {
|
||||
device: 'የአይ ፒ አድራሻ',
|
||||
signedIn: 'የገባበት ጊዜ',
|
||||
expires: 'የሚያበቃበት',
|
||||
status: 'ሁኔታ',
|
||||
actions: 'እርምጃዎች',
|
||||
},
|
||||
select: 'ይምረጡ',
|
||||
selectAll: 'ሁሉንም ክፍለ ጊዜዎች ይምረጡ',
|
||||
selectRow: 'ከ {{device}} የመጣውን ክፍለ ጊዜ ይምረጡ',
|
||||
thisDevice: 'ይህ መሣሪያ',
|
||||
revoke: 'ሰርዝ',
|
||||
cannotRevokeCurrent: 'ይህ አሁን እየተጠቀሙበት ያለው ክፍለ ጊዜ ነው።',
|
||||
revokeSelected_one: 'የተመረጠውን {{count}} ሰርዝ',
|
||||
revokeSelected_other: 'የተመረጡትን {{count}} ሰርዝ',
|
||||
signOutOthers: 'ከሌሎች ቦታዎች ሁሉ ውጣ',
|
||||
empty: 'ንቁ ክፍለ ጊዜ የለም።',
|
||||
confirm: {
|
||||
title: 'ክፍለ ጊዜ ሰርዝ',
|
||||
one: 'ከ {{device}} የመጣው ክፍለ ጊዜ ወዲያውኑ ይወጣል።',
|
||||
selected_one: '{{count}} ክፍለ ጊዜ ወዲያውኑ ይወጣል።',
|
||||
selected_other: '{{count}} ክፍለ ጊዜዎች ወዲያውኑ ይወጣሉ።',
|
||||
others: 'ሌሎቹ ክፍለ ጊዜዎች በሙሉ ወዲያውኑ ይወጣሉ።',
|
||||
unknownDevice: 'ይህ አሁን እየተጠቀሙበት ያለውን መሣሪያ ሊያካትት ይችላል።',
|
||||
},
|
||||
revoked_one: '{{count}} ክፍለ ጊዜ ተሰርዟል',
|
||||
revoked_other: '{{count}} ክፍለ ጊዜዎች ተሰርዘዋል',
|
||||
},
|
||||
twoStep: {
|
||||
title: "ባለሁለት ደረጃ ማረጋገጫ",
|
||||
desc: "በየጊዜው ሲገቡ ከስልክዎ የአንድ ጊዜ ኮድ ያስፈልጋል።",
|
||||
saved: "ባለሁለት ደረጃ ማረጋገጫ ተዘምኗል",
|
||||
},
|
||||
layout: {
|
||||
title: "አቀማመጥ",
|
||||
@@ -952,6 +985,9 @@ export const am: Translations = {
|
||||
needsFlags: "ማስተካከያ ለመጠየቅ ቢያንስ አንድ ነገር ምልክት ያድርጉ",
|
||||
needsCapital: "መጀመሪያ የተረጋገጠውን ካፒታል ይመዝግቡ",
|
||||
needsInspection: "የምርመራ ውጤት ያስፈልጋል",
|
||||
needsDocumentReviews:
|
||||
"መጀመሪያ ሁሉንም ሰነዶች ይቀበሉ — ከ{{total}} {{accepted}} ተቀብለዋል። የሰነዶች ትር ከፍተው ቀሪዎቹን ይቀበሉ።",
|
||||
needsDocumentsUploaded: "እስካሁን የሚገመገም ሰነድ አልተጫነም",
|
||||
},
|
||||
reasons: {
|
||||
incompleteDocuments: "ያልተሟሉ ሰነዶች",
|
||||
|
||||
@@ -65,7 +65,6 @@ export const en = {
|
||||
userManagement: 'User Management',
|
||||
seamanBookQueue: 'Seaman Book Queue',
|
||||
btcQueue: 'BTC Queue',
|
||||
vesselRegistrationHeadDashboard: 'Vessel Registration Head Dashboard',
|
||||
vesselRegistrationApplicationQueue: 'Vessel Registration Queue',
|
||||
vesselRegistrationQueue: 'Vessel Register',
|
||||
vesselFormBuilder: 'Vessel Form Builder',
|
||||
@@ -105,6 +104,8 @@ export const en = {
|
||||
|
||||
common: {
|
||||
logout: 'Log out',
|
||||
idleLogoutTitle: 'Session ended',
|
||||
idleLogoutMessage: 'You were signed out after 15 minutes of inactivity.',
|
||||
profile: 'Profile',
|
||||
settings: 'Settings',
|
||||
export: 'Export',
|
||||
@@ -452,9 +453,41 @@ export const en = {
|
||||
dark: 'Dark',
|
||||
system: 'System',
|
||||
},
|
||||
sessions: {
|
||||
title: 'Active sessions',
|
||||
hint: 'Devices currently signed in to your account. Revoke any you do not recognise.',
|
||||
columns: {
|
||||
device: 'IP address',
|
||||
signedIn: 'Signed in',
|
||||
expires: 'Expires',
|
||||
status: 'Status',
|
||||
actions: 'Actions',
|
||||
},
|
||||
select: 'Select',
|
||||
selectAll: 'Select all sessions',
|
||||
selectRow: 'Select session from {{device}}',
|
||||
thisDevice: 'This device',
|
||||
revoke: 'Revoke',
|
||||
cannotRevokeCurrent: 'This is the session you are using now.',
|
||||
revokeSelected_one: 'Revoke {{count}} selected',
|
||||
revokeSelected_other: 'Revoke {{count}} selected',
|
||||
signOutOthers: 'Sign out everywhere else',
|
||||
empty: 'No active sessions.',
|
||||
confirm: {
|
||||
title: 'Revoke session',
|
||||
one: 'The session from {{device}} will be signed out immediately.',
|
||||
selected_one: '{{count}} session will be signed out immediately.',
|
||||
selected_other: '{{count}} sessions will be signed out immediately.',
|
||||
others: 'Every other session will be signed out immediately.',
|
||||
unknownDevice: 'This may include the device you are using now.',
|
||||
},
|
||||
revoked_one: '{{count}} session revoked',
|
||||
revoked_other: '{{count}} sessions revoked',
|
||||
},
|
||||
twoStep: {
|
||||
title: 'Two-step verification',
|
||||
desc: 'Require a one-time code from your phone each time you sign in.',
|
||||
saved: 'Two-step verification updated',
|
||||
},
|
||||
layout: {
|
||||
title: 'Layout',
|
||||
@@ -959,6 +992,9 @@ export const en = {
|
||||
needsFlags: 'Flag at least one item to request a correction',
|
||||
needsCapital: 'Record the verified capital first',
|
||||
needsInspection: 'Requires an inspection result',
|
||||
needsDocumentReviews:
|
||||
'Accept all documents first — {{accepted}} of {{total}} accepted. Open the Documents tab and accept the rest.',
|
||||
needsDocumentsUploaded: 'No documents uploaded to review yet',
|
||||
},
|
||||
reasons: {
|
||||
incompleteDocuments: 'Incomplete documents',
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { AppShell } from '@mantine/core';
|
||||
import { AppShell, Drawer } from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { BrandMark, logout } from '@ema-platform/auth';
|
||||
import { BrandMark, logout, useIdleTimer } from '@ema-platform/auth';
|
||||
import { AppHeader, AppSidebar } from '@ema-platform/ui';
|
||||
import type { NavItem, NavSection } from '@ema-platform/ui';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
@@ -26,26 +26,24 @@ const BADGE_POLL_MS = 60_000;
|
||||
|
||||
const HEADER_HEIGHT = 116;
|
||||
|
||||
/**
|
||||
* A desk left unlocked with a license-review or medical-record screen open is
|
||||
* the actual threat model here, not a slow token. 15 minutes of no mouse,
|
||||
* key, scroll, or touch activity signs the officer out automatically.
|
||||
*/
|
||||
const IDLE_TIMEOUT_MS = 15 * 60 * 1000;
|
||||
|
||||
export function BackofficeLayout() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const dispatch = useAppDispatch();
|
||||
const [opened, { toggle: toggleNav }] = useDisclosure();
|
||||
const [opened, { toggle: toggleNav, close: closeNav }] = useDisclosure();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const user = useAppSelector((state) => state.auth.user);
|
||||
const layoutMode = useAppSelector((state) => state.preferences.layoutMode);
|
||||
const { permissions: granted, known } = usePermissions();
|
||||
|
||||
// TEMPORARY diagnostic — remove once the sidebar is confirmed working.
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
'[NAV] known=', known,
|
||||
'granted=', granted.length,
|
||||
'| cookies:', document.cookie.split('; ').map((c) => c.split('=')[0]).filter((n) => n.includes('token')),
|
||||
'| token tail:', (document.cookie.match(/ema-backoffice-auth-token=([^;]+)/)?.[1] ?? 'NONE').slice(-12),
|
||||
);
|
||||
|
||||
// Badges reflect real pending work. One grouped request on a timer, shared
|
||||
// by the sidebar and the top bar via the RTK cache.
|
||||
const { data: counts } = useGetQueueCountsQuery(undefined, {
|
||||
@@ -89,9 +87,20 @@ export function BackofficeLayout() {
|
||||
const handleLogout = useCallback(() => {
|
||||
dispatch(logout());
|
||||
dispatch(baseApi.util.resetApiState());
|
||||
navigate("/login");
|
||||
navigate("/");
|
||||
}, [dispatch, navigate]);
|
||||
|
||||
useIdleTimer(IDLE_TIMEOUT_MS, () => {
|
||||
notify.info(
|
||||
t(
|
||||
'common.idleLogoutMessage',
|
||||
'You were signed out after 15 minutes of inactivity.',
|
||||
),
|
||||
t('common.idleLogoutTitle', 'Session ended'),
|
||||
);
|
||||
handleLogout();
|
||||
});
|
||||
|
||||
const segments = location.pathname.split('/').filter(Boolean);
|
||||
// Label each crumb from the nav item it corresponds to, falling back to a
|
||||
// readable form of the path segment. Every crumb was previously labelled
|
||||
@@ -141,7 +150,10 @@ export function BackofficeLayout() {
|
||||
? {
|
||||
width: collapsed ? 72 : 264,
|
||||
breakpoint: "sm",
|
||||
collapsed: { mobile: !opened },
|
||||
// Mobile has its own Drawer below — AppShell's built-in mobile
|
||||
// navbar takes over the full viewport width, which felt like
|
||||
// it swallowed the page. Always collapsed here on mobile.
|
||||
collapsed: { mobile: true },
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
@@ -222,6 +234,34 @@ export function BackofficeLayout() {
|
||||
|
||||
{/* Registered once for the whole backoffice; opens on ⌘K anywhere. */}
|
||||
<CommandPalette sections={sections} />
|
||||
|
||||
{/* Mobile nav: a proper Drawer (sized, backdrop, closes on outside
|
||||
click) instead of AppShell's full-width mobile navbar. Mirrors the
|
||||
landing page's mobile menu. */}
|
||||
{isSidebar && (
|
||||
<Drawer
|
||||
opened={opened}
|
||||
onClose={closeNav}
|
||||
hiddenFrom="sm"
|
||||
size="75%"
|
||||
padding={0}
|
||||
withCloseButton={false}
|
||||
>
|
||||
<AppSidebar
|
||||
navItems={sections}
|
||||
collapsed={false}
|
||||
activePath={location.pathname}
|
||||
onToggleCollapse={handleToggleCollapse}
|
||||
onNavigate={(item) => {
|
||||
go(item);
|
||||
closeNav();
|
||||
}}
|
||||
brandName={t('app.name')}
|
||||
brandSubtitle={t('app.authority')}
|
||||
brandLogo={<BrandMark size={32} />}
|
||||
/>
|
||||
</Drawer>
|
||||
)}
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -108,12 +108,11 @@ export const NAV_SECTIONS: NavSection[] = [
|
||||
{
|
||||
label: 'nav.groupVessels',
|
||||
items: [
|
||||
{ to: '/licence-review/type/VESSEL_REGISTRATION', label: 'nav.vesselRegistrationApplicationQueue', icon: IconAnchor, permissions: APPLICATION_QUEUE },
|
||||
{ to: '/vessel-registration-report', label: 'nav.vesselRegistrationReport', icon: IconChartBar, permissions: [P.VIEW_VESSEL_REGISTRY] },
|
||||
{ to: '/vessel-registration-queue', label: 'nav.vesselRegistrationQueue', icon: IconAnchor, permissions: [P.VIEW_VESSEL_REGISTRY] },
|
||||
{ to: '/licence-review/type/VESSEL_REGISTRATION', label: 'nav.vesselRegistrationApplicationQueue', icon: IconAnchor, permissions: APPLICATION_QUEUE },
|
||||
{ to: '/licence-review/type/VESSEL_OWNERSHIP_TRANSFER', label: 'nav.ownershipTransferQueue', icon: IconArrowsExchange, permissions: APPLICATION_QUEUE },
|
||||
{ to: '/vessel-registration-queue/new', label: 'nav.vesselFormBuilder', icon: IconFilePlus, soon: true, permissions: [P.VIEW_VESSEL_REGISTRY] },
|
||||
{ to: '/vessel-registration-report', label: 'nav.vesselRegistrationReport', icon: IconChartBar, soon: true, permissions: [P.VIEW_VESSEL_REGISTRY] },
|
||||
{ to: '/vessel-registration-head-dashboard', label: 'nav.vesselRegistrationHeadDashboard', icon: IconGauge, soon: true, permissions: [P.VIEW_VESSEL_REGISTRY] },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import { MantineProvider, mergeThemeOverrides } from '@mantine/core';
|
||||
import { Notifications } from '@mantine/notifications';
|
||||
import { emaTheme } from '@ema-platform/shared';
|
||||
import { maritimeLoaderTheme } from '@ema-platform/ui';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
const theme = mergeThemeOverrides(emaTheme, maritimeLoaderTheme);
|
||||
|
||||
export function MantineThemeProvider({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<MantineProvider theme={emaTheme} defaultColorScheme="light">
|
||||
<MantineProvider theme={theme} defaultColorScheme="light">
|
||||
<Notifications position="top-right" />
|
||||
{children}
|
||||
</MantineProvider>
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import Cookies from 'js-cookie';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { LandingPage } from '@ema-platform/ui';
|
||||
import { authStorage } from '@ema-platform/auth';
|
||||
import { useAuthToken } from '@ema-platform/auth';
|
||||
|
||||
/**
|
||||
* Public `/` — mounts the shared landing page. Backoffice has no /signup
|
||||
* (enableSignup: false) and no /verify route, so those props are omitted.
|
||||
* Public `/`. Signed-in visitors skip the landing page entirely and go
|
||||
* straight to the dashboard. Backoffice has no /signup (enableSignup: false)
|
||||
* and no /verify route, so those props are omitted.
|
||||
*/
|
||||
export function LandingRoute() {
|
||||
const token = authStorage.getToken() ?? Cookies.get('auth-token');
|
||||
const token = useAuthToken();
|
||||
|
||||
return <LandingPage primaryHref={token ? '/dashboard' : '/login'} />;
|
||||
if (token) return <Navigate to="/dashboard" replace />;
|
||||
|
||||
return <LandingPage primaryHref="/login" />;
|
||||
}
|
||||
|
||||
@@ -37,7 +37,6 @@ import { VesselRegistrationQueuePage } from '../features/vessel-registration/pag
|
||||
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 { VesselRegistrationHeadDashboardPage } from '../features/vessel-registration-head/pages/VesselRegistrationHeadDashboardPage';
|
||||
import { LicenseQueuePage } from '../features/license-review/pages/LicenseQueuePage';
|
||||
import { LicenseRegisterPage } from '../features/license-register/pages/LicenseRegisterPage';
|
||||
import { LicenseReviewPage } from '../features/license-review/pages/LicenseReviewPage';
|
||||
@@ -73,7 +72,6 @@ const router = createBrowserRouter([
|
||||
element: <BackofficeLayout />,
|
||||
children: [
|
||||
{ path: 'dashboard', element: <DashboardPage /> },
|
||||
{ path: 'vessel-registration-head-dashboard', element: guard([P.VIEW_VESSEL_REGISTRY], <VesselRegistrationHeadDashboardPage />) },
|
||||
{ path: 'logistics-head-dashboard', element: guard(APPLICATION_QUEUE, <LogisticsHeadDashboardPage />) },
|
||||
{ path: 'profile', element: <ProfilePage /> },
|
||||
{ path: 'configuration', element: guard([P.VIEW_LICENSE_TYPES, P.VIEW_TEMPLATES], <ConfigurationPage />) },
|
||||
@@ -101,7 +99,7 @@ const router = createBrowserRouter([
|
||||
{ path: 'vessel-registration-queue', element: guard([P.VIEW_VESSEL_REGISTRY], <VesselRegistrationQueuePage />) },
|
||||
{ path: 'vessel-registration-queue/new', element: guard([P.VIEW_VESSEL_REGISTRY], <VesselRegistrationFormBuilderPage />) },
|
||||
// { path: 'vessel-registration-queue/:id', element: <VesselRegistrationReviewPage /> },
|
||||
//{ path: 'vessel-registration-report', element: <VesselRegistrationReportPage /> },
|
||||
{ path: 'vessel-registration-report', element: guard([P.VIEW_VESSEL_REGISTRY], <VesselRegistrationReportPage />) },
|
||||
{ path: 'vessel-ownership-transfer', element: <Navigate to="/licence-review/type/VESSEL_OWNERSHIP_TRANSFER" replace /> },
|
||||
{ path: 'vessel-ownership-transfer/:id', element: <Navigate to="/licence-review" replace /> },
|
||||
// Config-driven review workspace, shared by every licence type.
|
||||
|
||||
@@ -51,7 +51,7 @@ configureTokenRefresh({
|
||||
},
|
||||
onAuthFailure: () => {
|
||||
store.dispatch(logout());
|
||||
window.location.href = '/login';
|
||||
window.location.href = '/';
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -13,6 +13,15 @@ export default defineConfig({
|
||||
port: 4201,
|
||||
host: 'localhost',
|
||||
},
|
||||
// server: {
|
||||
// port: 4201,
|
||||
// proxy: {
|
||||
// '/api': {
|
||||
// target: 'http://localhost:3000', // change from 'https://ema-api-dev.triaplc.com'
|
||||
// changeOrigin: true,
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
preview: { port: 4201, host: 'localhost' },
|
||||
plugins: [react(), nxViteTsPaths()],
|
||||
resolve: {
|
||||
@@ -33,4 +42,14 @@ export default defineConfig({
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
},
|
||||
// Unit tests for the pure helpers behind a screen (formatters, URL state).
|
||||
// Component tests are deliberately not set up: nothing here renders React,
|
||||
// so no jsdom environment or setup file is needed.
|
||||
// test: {
|
||||
// watch: false,
|
||||
// globals: true,
|
||||
// environment: 'node',
|
||||
// include: ['src/**/*.spec.ts'],
|
||||
// reporters: ['default'],
|
||||
// },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user