mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 13:02:50 +00:00
Merge pull request #25 from Tria-plc/estif-branch-1
fixes on vessel report by tade
This commit is contained in:
@@ -94,10 +94,16 @@ export function DocumentRequirementEditorDrawer({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!draft.name.en?.trim()) return;
|
if (!draft.name.en?.trim()) return;
|
||||||
|
// A condition is either a single-field check or an anyOf list — the CoP
|
||||||
|
// watch_rating_certificate requirement is seeded with anyOf and no field.
|
||||||
const hasCondition =
|
const hasCondition =
|
||||||
Boolean(draft.conditionExpression?.field) ||
|
Boolean(draft.conditionExpression?.field) ||
|
||||||
Boolean(draft.conditionExpression?.anyOf?.length);
|
Boolean(draft.conditionExpression?.anyOf?.length);
|
||||||
if (draft.mode === 'CONDITIONAL' && !hasCondition) {
|
if (
|
||||||
|
draft.mode === 'CONDITIONAL' &&
|
||||||
|
!hasCondition &&
|
||||||
|
!draft.conditionExpression?.anyOf?.length
|
||||||
|
) {
|
||||||
setKeyError(t('certReq.doc.conditionRequired', 'A conditional requirement needs a condition'));
|
setKeyError(t('certReq.doc.conditionRequired', 'A conditional requirement needs a condition'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -358,6 +358,18 @@ export function resolveActions(ctx: ResolveContext): ResolvedAction[] {
|
|||||||
if (action.id === 'schedule-inspection' && ctx.hasPendingInspection) return [];
|
if (action.id === 'schedule-inspection' && ctx.hasPendingInspection) return [];
|
||||||
if (action.id === 'record-inspection' && !ctx.hasPendingInspection) return [];
|
if (action.id === 'record-inspection' && !ctx.hasPendingInspection) return [];
|
||||||
|
|
||||||
|
// The transition table doesn't know which types need an inspection, so
|
||||||
|
// `availableEvents` lists approve-documents at UNDER_EVALUATION even for
|
||||||
|
// types without one — where the server refuses it
|
||||||
|
// (`inspection_not_required_use_final_approve`). Final Approve is the
|
||||||
|
// real action there; don't render its dead twin.
|
||||||
|
if (
|
||||||
|
action.id === 'approve-documents' &&
|
||||||
|
!app.licenseType?.inspectionRequired
|
||||||
|
) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
const disabled = (reason: string): ResolvedAction => ({
|
const disabled = (reason: string): ResolvedAction => ({
|
||||||
...action,
|
...action,
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
|||||||
@@ -116,11 +116,21 @@ const PRESENTATION: Record<string, LicenseTypePresentation> = {
|
|||||||
// no capital threshold, no staff roles.
|
// no capital threshold, no staff roles.
|
||||||
detailSections: ['overview', 'documents'],
|
detailSections: ['overview', 'documents'],
|
||||||
},
|
},
|
||||||
|
ENDORSEMENT_SEAFARER: {
|
||||||
|
key: 'ENDORSEMENT_SEAFARER',
|
||||||
|
icon: IconRubberStamp,
|
||||||
|
// Person-centric, same as seafarer registration: no company entity, no
|
||||||
|
// capital threshold, no staff roles, no inspection. Covers CoC and GOC
|
||||||
|
// together — the `endorsementScope` field on the application decides
|
||||||
|
// which certificate section(s) actually have data.
|
||||||
|
detailSections: ['overview', 'documents'],
|
||||||
|
},
|
||||||
|
// Retired by ENDORSEMENT_SEAFARER (see endorsements.seed-data.ts). Kept so
|
||||||
|
// an application filed before the switch still renders with the right
|
||||||
|
// presentation instead of falling back to the generic company layout.
|
||||||
ENDORSEMENT_COC: {
|
ENDORSEMENT_COC: {
|
||||||
key: 'ENDORSEMENT_COC',
|
key: 'ENDORSEMENT_COC',
|
||||||
icon: IconRubberStamp,
|
icon: IconRubberStamp,
|
||||||
// Person-centric, same as seafarer registration: no company entity, no
|
|
||||||
// capital threshold, no staff roles, no inspection.
|
|
||||||
detailSections: ['overview', 'documents'],
|
detailSections: ['overview', 'documents'],
|
||||||
},
|
},
|
||||||
ENDORSEMENT_GOC: {
|
ENDORSEMENT_GOC: {
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ import {
|
|||||||
SAVED_VIEWS,
|
SAVED_VIEWS,
|
||||||
filterFromSearchParams,
|
filterFromSearchParams,
|
||||||
readLastView,
|
readLastView,
|
||||||
|
hasUnclaimedPool,
|
||||||
savedViewsForFamily,
|
savedViewsForFamily,
|
||||||
searchParamsFromFilter,
|
searchParamsFromFilter,
|
||||||
writeLastView,
|
writeLastView,
|
||||||
@@ -159,12 +160,16 @@ export function LicenseQueuePage() {
|
|||||||
const isLogistics = typeCode
|
const isLogistics = typeCode
|
||||||
? resolveFamilyKind(typeCode) === "LOGISTICS_LICENSE"
|
? resolveFamilyKind(typeCode) === "LOGISTICS_LICENSE"
|
||||||
: undefined;
|
: undefined;
|
||||||
const visibleViews = savedViewsForFamily(isLogistics !== false);
|
// Claiming is a workflow property, not a label one — Vessel Registration is
|
||||||
|
// a DOCUMENT family but is still triaged off a shared unclaimed pool, so it
|
||||||
|
// keeps the Unassigned tab and the claim actions.
|
||||||
|
const claimable = hasUnclaimedPool(typeCode);
|
||||||
|
const visibleViews = savedViewsForFamily(claimable);
|
||||||
|
|
||||||
const [view, setView] = useState<SavedViewId>(
|
const [view, setView] = useState<SavedViewId>(
|
||||||
() =>
|
() =>
|
||||||
(searchParams.get("view") as SavedViewId) ||
|
(searchParams.get("view") as SavedViewId) ||
|
||||||
(isLogistics === false ? "all" : readLastView()),
|
(claimable ? readLastView() : "all"),
|
||||||
);
|
);
|
||||||
const [page, setPage] = useState(() => Number(searchParams.get("page")) || 1);
|
const [page, setPage] = useState(() => Number(searchParams.get("page")) || 1);
|
||||||
const [pageSize, setPageSize] = useState(PAGE_SIZE);
|
const [pageSize, setPageSize] = useState(PAGE_SIZE);
|
||||||
@@ -174,21 +179,17 @@ export function LicenseQueuePage() {
|
|||||||
const [helpOpen, setHelpOpen] = useState(false);
|
const [helpOpen, setHelpOpen] = useState(false);
|
||||||
const [debouncedSearch] = useDebouncedValue(searchInput, SEARCH_DEBOUNCE_MS);
|
const [debouncedSearch] = useDebouncedValue(searchInput, SEARCH_DEBOUNCE_MS);
|
||||||
|
|
||||||
// Non-logistics queues have no unassigned/unclaimed pool (see
|
// Queues with no unclaimed pool (see `hasUnclaimedPool`) have no unassigned
|
||||||
// `savedViewsForFamily`), so a stale "unassigned" view — e.g. restored from
|
// tab, so a stale "unassigned" view — e.g. restored from
|
||||||
// `readLastView()` — must fall back to "all" rather than land on a tab that
|
// `readLastView()` — must fall back to "all" rather than land on a tab that
|
||||||
// no longer exists. Auto-created BTC requests specifically start at
|
// no longer exists. Auto-created BTC requests specifically start at
|
||||||
// PAYMENT_PENDING, outside "mine" too, so "all" is the one view guaranteed
|
// PAYMENT_PENDING, outside "mine" too, so "all" is the one view guaranteed
|
||||||
// to show them.
|
// to show them.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (!claimable && !searchParams.has("view") && view === "unassigned") {
|
||||||
isLogistics === false &&
|
|
||||||
!searchParams.has("view") &&
|
|
||||||
view === "unassigned"
|
|
||||||
) {
|
|
||||||
setView("all");
|
setView("all");
|
||||||
}
|
}
|
||||||
}, [isLogistics, searchParams, view]);
|
}, [claimable, searchParams, view]);
|
||||||
|
|
||||||
const urlFilter = useMemo(
|
const urlFilter = useMemo(
|
||||||
() => filterFromSearchParams(searchParams),
|
() => filterFromSearchParams(searchParams),
|
||||||
@@ -414,9 +415,9 @@ export function LicenseQueuePage() {
|
|||||||
onPrevious: () => setCursor((c) => Math.max(c - 1, 0)),
|
onPrevious: () => setCursor((c) => Math.max(c - 1, 0)),
|
||||||
onOpen: () => cursorRow && navigate(`/licence-review/${cursorRow.id}`),
|
onOpen: () => cursorRow && navigate(`/licence-review/${cursorRow.id}`),
|
||||||
onClaim: () => {
|
onClaim: () => {
|
||||||
// Only unclaimed rows on a logistics queue can be claimed; pressing c
|
// Only unclaimed rows on a claimable queue can be claimed; pressing c
|
||||||
// elsewhere is a no-op rather than an error the officer has to read.
|
// elsewhere is a no-op rather than an error the officer has to read.
|
||||||
if (isLogistics !== false && cursorRow && cursorRow.assignedOfficerId === null)
|
if (claimable && cursorRow && cursorRow.assignedOfficerId === null)
|
||||||
handleClaim(cursorRow.id);
|
handleClaim(cursorRow.id);
|
||||||
},
|
},
|
||||||
onEscape: () => setSelected([]),
|
onEscape: () => setSelected([]),
|
||||||
@@ -481,9 +482,9 @@ export function LicenseQueuePage() {
|
|||||||
claiming,
|
claiming,
|
||||||
onClaim: handleClaim,
|
onClaim: handleClaim,
|
||||||
onOpen: (id) => navigate(`/licence-review/${id}`),
|
onOpen: (id) => navigate(`/licence-review/${id}`),
|
||||||
// Non-logistics applications aren't claimed off a shared queue (see
|
// Applications with no unclaimed pool (see `hasUnclaimedPool`) are
|
||||||
// `savedViewsForFamily`) — every row opens straight to Review.
|
// never claimed — every row opens straight to Review.
|
||||||
claimable: isLogistics !== false,
|
claimable,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
@@ -496,6 +497,7 @@ export function LicenseQueuePage() {
|
|||||||
items,
|
items,
|
||||||
claiming,
|
claiming,
|
||||||
isLogistics,
|
isLogistics,
|
||||||
|
claimable,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -763,7 +765,7 @@ export function LicenseQueuePage() {
|
|||||||
>
|
>
|
||||||
{t("queue.export", "Export CSV")}
|
{t("queue.export", "Export CSV")}
|
||||||
</Button>
|
</Button>
|
||||||
{isLogistics !== false && (
|
{claimable && (
|
||||||
<RequirePermission
|
<RequirePermission
|
||||||
anyOf={[LICENSE_PERMISSIONS.CLAIM_APPLICATION]}
|
anyOf={[LICENSE_PERMISSIONS.CLAIM_APPLICATION]}
|
||||||
hideOnly
|
hideOnly
|
||||||
|
|||||||
@@ -533,11 +533,11 @@ export function LicenseReviewPage() {
|
|||||||
try {
|
try {
|
||||||
switch (action.id) {
|
switch (action.id) {
|
||||||
case "claim":
|
case "claim":
|
||||||
// Usually fired from the queue, but an officer can also open an
|
// Usually fired from the queue, but an officer who opened an
|
||||||
// unclaimed application directly and claim it from here.
|
// unclaimed application directly claims it from here.
|
||||||
await run(
|
await run(
|
||||||
() => claimApplication(id).unwrap(),
|
() => claimApplication(id).unwrap(),
|
||||||
t("review.done.claim", "Application claimed"),
|
t("review.done.claim", "Claimed — the application is now yours"),
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case "complete-review":
|
case "complete-review":
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { resolveFamilyKind } from "@ema-platform/api";
|
import { resolveFamilyKind } from "@ema-platform/api";
|
||||||
|
import { hasUnclaimedPool, savedViewsForFamily } from "./queue-views";
|
||||||
|
|
||||||
describe("resolveFamilyKind", () => {
|
describe("resolveFamilyKind", () => {
|
||||||
it("treats person-centric seafarer applications as document queues", () => {
|
it("treats person-centric seafarer applications as document queues", () => {
|
||||||
@@ -8,3 +9,28 @@ describe("resolveFamilyKind", () => {
|
|||||||
expect(resolveFamilyKind("BTC_BASIC_TRAINING")).toBe("CERTIFICATE");
|
expect(resolveFamilyKind("BTC_BASIC_TRAINING")).toBe("CERTIFICATE");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("hasUnclaimedPool", () => {
|
||||||
|
it("keeps the unassigned pool for logistics licences", () => {
|
||||||
|
expect(hasUnclaimedPool("FREIGHT_FORWARDER")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps it for vessel registration, a DOCUMENT queue that is still claimed", () => {
|
||||||
|
expect(resolveFamilyKind("VESSEL_REGISTRATION")).toBe("DOCUMENT");
|
||||||
|
expect(hasUnclaimedPool("VESSEL_REGISTRATION")).toBe(true);
|
||||||
|
expect(savedViewsForFamily(hasUnclaimedPool("VESSEL_REGISTRATION")).map((v) => v.id)).toContain(
|
||||||
|
"unassigned",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops it for the person-centric services", () => {
|
||||||
|
expect(hasUnclaimedPool("SEAMAN_BOOK")).toBe(false);
|
||||||
|
expect(savedViewsForFamily(hasUnclaimedPool("SEAMAN_BOOK")).map((v) => v.id)).not.toContain(
|
||||||
|
"unassigned",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps it for the mixed All/Mine grids, where no type is pinned", () => {
|
||||||
|
expect(hasUnclaimedPool(undefined)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { resolveFamilyKind } from '@ema-platform/api';
|
||||||
import type { LicenseStatus, QueueCounts, QueueFilter } from '@ema-platform/api';
|
import type { LicenseStatus, QueueCounts, QueueFilter } from '@ema-platform/api';
|
||||||
|
|
||||||
export type SavedViewId =
|
export type SavedViewId =
|
||||||
@@ -78,12 +79,39 @@ export const SAVED_VIEWS: SavedView[] = [
|
|||||||
export const DEFAULT_VIEW: SavedViewId = 'unassigned';
|
export const DEFAULT_VIEW: SavedViewId = 'unassigned';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Non-logistics queues (Seafarer Registration, Seaman Book, BTC, CoC, ...)
|
* Type keys reviewed off a shared unclaimed pool despite not being logistics
|
||||||
* have no unclaimed pool to triage — those applications aren't claimed off a
|
* licences. Whether a queue is claimed is an officer-workflow property, not a
|
||||||
* shared queue — so the tab that lists it doesn't apply there.
|
* label one: vessel registrations arrive unassigned and officers claim them,
|
||||||
|
* even though the family kind is DOCUMENT because the certificate they produce
|
||||||
|
* is a document rather than a licence.
|
||||||
*/
|
*/
|
||||||
export function savedViewsForFamily(isLogistics: boolean): SavedView[] {
|
const CLAIMABLE_NON_LOGISTICS = new Set([
|
||||||
return isLogistics
|
'VESSEL_REGISTRATION',
|
||||||
|
// Endorsements run the standard claim-first workflow: applications arrive
|
||||||
|
// unassigned and an officer claims them, even though the family kind is
|
||||||
|
// CERTIFICATE.
|
||||||
|
'ENDORSEMENT_SEAFARER',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Does this queue have an unclaimed pool to triage?
|
||||||
|
*
|
||||||
|
* True for the mixed All/Mine grids (no type pinned) — nothing is hidden when
|
||||||
|
* the queue spans every type. False for the person-centric services (Seafarer
|
||||||
|
* Registration, Seaman Book, BTC, CoC, ...), whose applications aren't claimed
|
||||||
|
* off a shared queue, so the tab and the claim actions don't apply there.
|
||||||
|
*/
|
||||||
|
export function hasUnclaimedPool(typeCode: string | undefined): boolean {
|
||||||
|
if (!typeCode) return true;
|
||||||
|
return (
|
||||||
|
resolveFamilyKind(typeCode) === 'LOGISTICS_LICENSE' ||
|
||||||
|
CLAIMABLE_NON_LOGISTICS.has(typeCode)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drops the Unassigned tab on queues with no unclaimed pool. */
|
||||||
|
export function savedViewsForFamily(claimable: boolean): SavedView[] {
|
||||||
|
return claimable
|
||||||
? SAVED_VIEWS
|
? SAVED_VIEWS
|
||||||
: SAVED_VIEWS.filter((v) => v.id !== 'unassigned');
|
: SAVED_VIEWS.filter((v) => v.id !== 'unassigned');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export const am: Translations = {
|
|||||||
typeVESSEL_OWNERSHIP_TRANSFER: "የመርከብ ባለቤትነት ዝውውር",
|
typeVESSEL_OWNERSHIP_TRANSFER: "የመርከብ ባለቤትነት ዝውውር",
|
||||||
typeCERTIFICATE_OF_COMPETENCY: "የብቃት ማረጋገጫ ምስክር ወረቀት",
|
typeCERTIFICATE_OF_COMPETENCY: "የብቃት ማረጋገጫ ምስክር ወረቀት",
|
||||||
typeCERTIFICATE_OF_PROFICIENCY: "የብቃት ምስክር ወረቀት",
|
typeCERTIFICATE_OF_PROFICIENCY: "የብቃት ምስክር ወረቀት",
|
||||||
|
typeENDORSEMENT_SEAFARER: "የባህረኛ ማስተያየት (CoC / GOC)",
|
||||||
typeENDORSEMENT_COC: "የCoC ማረጋገጫ",
|
typeENDORSEMENT_COC: "የCoC ማረጋገጫ",
|
||||||
typeENDORSEMENT_GOC: "የGOC ማረጋገጫ",
|
typeENDORSEMENT_GOC: "የGOC ማረጋገጫ",
|
||||||
primary: "ዋና",
|
primary: "ዋና",
|
||||||
@@ -87,8 +88,7 @@ export const am: Translations = {
|
|||||||
btcQueue: "የBTC ወረፋ",
|
btcQueue: "የBTC ወረፋ",
|
||||||
cocQueue: "የCoC ወረፋ",
|
cocQueue: "የCoC ወረፋ",
|
||||||
copQueue: "የCoP ወረፋ",
|
copQueue: "የCoP ወረፋ",
|
||||||
endorsementCocQueue: "የCoC ማረጋገጫ ወረፋ",
|
endorsementQueue: "የማስተያየት ወረፋ",
|
||||||
endorsementGocQueue: "የGOC ማረጋገጫ ወረፋ",
|
|
||||||
vesselRegistrations: "የመርከብ ምዝገባ",
|
vesselRegistrations: "የመርከብ ምዝገባ",
|
||||||
// vesselRegistrationReport: 'የምዝገባ ሪፖርት',
|
// vesselRegistrationReport: 'የምዝገባ ሪፖርት',
|
||||||
vesselTransfers: "የመርከብ ባለቤትነት ዝውውር",
|
vesselTransfers: "የመርከብ ባለቤትነት ዝውውር",
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export const en = {
|
|||||||
typeVESSEL_OWNERSHIP_TRANSFER: 'Vessel Ownership Transfer',
|
typeVESSEL_OWNERSHIP_TRANSFER: 'Vessel Ownership Transfer',
|
||||||
typeCERTIFICATE_OF_COMPETENCY: 'Certificate of Competency',
|
typeCERTIFICATE_OF_COMPETENCY: 'Certificate of Competency',
|
||||||
typeCERTIFICATE_OF_PROFICIENCY: 'Certificate of Proficiency',
|
typeCERTIFICATE_OF_PROFICIENCY: 'Certificate of Proficiency',
|
||||||
|
typeENDORSEMENT_SEAFARER: 'Seafarer Endorsement (CoC / GOC)',
|
||||||
typeENDORSEMENT_COC: 'CoC Endorsement',
|
typeENDORSEMENT_COC: 'CoC Endorsement',
|
||||||
typeENDORSEMENT_GOC: 'GOC Endorsement',
|
typeENDORSEMENT_GOC: 'GOC Endorsement',
|
||||||
primary: 'Primary',
|
primary: 'Primary',
|
||||||
@@ -87,8 +88,7 @@ export const en = {
|
|||||||
postWaiverQueue: 'Post-Waiver Queue',
|
postWaiverQueue: 'Post-Waiver Queue',
|
||||||
cocQueue: 'CoC Queue',
|
cocQueue: 'CoC Queue',
|
||||||
copQueue: 'CoP Queue',
|
copQueue: 'CoP Queue',
|
||||||
endorsementCocQueue: 'CoC Endorsement Queue',
|
endorsementQueue: 'Endorsement Queue',
|
||||||
endorsementGocQueue: 'GOC Endorsement Queue',
|
|
||||||
vesselRegistrations: 'Vessel Registration',
|
vesselRegistrations: 'Vessel Registration',
|
||||||
vesselTransfers: 'Vessel Ownership Transfer',
|
vesselTransfers: 'Vessel Ownership Transfer',
|
||||||
seafarerRegistry: 'Seafarer Registry',
|
seafarerRegistry: 'Seafarer Registry',
|
||||||
|
|||||||
@@ -101,8 +101,7 @@ export const NAV_SECTIONS: NavSection[] = [
|
|||||||
{ to: '/licence-review/type/CERTIFICATE_OF_PROFICIENCY', label: 'nav.copQueue', icon: IconShieldCheck, permissions: APPLICATION_QUEUE },
|
{ to: '/licence-review/type/CERTIFICATE_OF_PROFICIENCY', label: 'nav.copQueue', icon: IconShieldCheck, permissions: APPLICATION_QUEUE },
|
||||||
{ to: '/seaman-book-queue', label: 'nav.seamanBookQueue', icon: IconBook2, permissions: APPLICATION_QUEUE },
|
{ to: '/seaman-book-queue', label: 'nav.seamanBookQueue', icon: IconBook2, permissions: APPLICATION_QUEUE },
|
||||||
{ to: '/btc-queue', label: 'nav.btcQueue', icon: IconShieldCheck, permissions: APPLICATION_QUEUE },
|
{ to: '/btc-queue', label: 'nav.btcQueue', icon: IconShieldCheck, permissions: APPLICATION_QUEUE },
|
||||||
{ to: '/licence-review/type/ENDORSEMENT_COC', label: 'nav.endorsementCocQueue', icon: IconRubberStamp, permissions: APPLICATION_QUEUE },
|
{ to: '/licence-review/type/ENDORSEMENT_SEAFARER', label: 'nav.endorsementQueue', icon: IconRubberStamp, permissions: APPLICATION_QUEUE },
|
||||||
{ to: '/licence-review/type/ENDORSEMENT_GOC', label: 'nav.endorsementGocQueue', icon: IconRubberStamp, permissions: APPLICATION_QUEUE },
|
|
||||||
{ to: '/sea-service-verification', label: 'nav.seaServiceVerification', icon: IconAnchor, permissions: [P.VERIFY_SEAFARER_RECORDS] },
|
{ to: '/sea-service-verification', label: 'nav.seaServiceVerification', icon: IconAnchor, permissions: [P.VERIFY_SEAFARER_RECORDS] },
|
||||||
{ to: '/medical-verification', label: 'nav.medicalVerification', icon: IconHeart, permissions: [P.VERIFY_SEAFARER_RECORDS] },
|
{ to: '/medical-verification', label: 'nav.medicalVerification', icon: IconHeart, permissions: [P.VERIFY_SEAFARER_RECORDS] },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -90,7 +90,11 @@ const router = createBrowserRouter([
|
|||||||
{ path: 'coc-queue', element: <Navigate to="/licence-review/type/CERTIFICATE_OF_COMPETENCY" replace /> },
|
{ path: 'coc-queue', element: <Navigate to="/licence-review/type/CERTIFICATE_OF_COMPETENCY" replace /> },
|
||||||
{ path: 'coc-queue/:id', element: <Navigate to="/licence-review" replace /> },
|
{ path: 'coc-queue/:id', element: <Navigate to="/licence-review" replace /> },
|
||||||
// Endorsement review happens in the config-driven licence queue.
|
// Endorsement review happens in the config-driven licence queue.
|
||||||
{ path: 'endorsement-queue', element: <Navigate to="/licence-review/type/ENDORSEMENT_COC" replace /> },
|
// CoC and GOC endorsement now share one combined type; the two old
|
||||||
|
// type queues (ENDORSEMENT_COC / ENDORSEMENT_GOC) are left routing
|
||||||
|
// through the generic `:typeCode` queue below rather than redirected,
|
||||||
|
// so an application filed before the switch stays reachable there.
|
||||||
|
{ path: 'endorsement-queue', element: <Navigate to="/licence-review/type/ENDORSEMENT_SEAFARER" replace /> },
|
||||||
{ path: 'endorsement-queue/:id', element: <Navigate to="/licence-review" replace /> },
|
{ path: 'endorsement-queue/:id', element: <Navigate to="/licence-review" replace /> },
|
||||||
{ path: 'medical-verification', element: guard([P.VERIFY_SEAFARER_RECORDS], <MedicalVerificationPage />) },
|
{ path: 'medical-verification', element: guard([P.VERIFY_SEAFARER_RECORDS], <MedicalVerificationPage />) },
|
||||||
{ path: 'sea-service-verification', element: guard([P.VERIFY_SEAFARER_RECORDS], <SeaServiceVerificationPage />) },
|
{ path: 'sea-service-verification', element: guard([P.VERIFY_SEAFARER_RECORDS], <SeaServiceVerificationPage />) },
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
TERMINAL_STATUSES,
|
TERMINAL_STATUSES,
|
||||||
extractErrorMessage,
|
extractErrorMessage,
|
||||||
useLocalized,
|
useLocalized,
|
||||||
|
useBypassPaymentMutation,
|
||||||
useGetCertificateUrlMutation,
|
useGetCertificateUrlMutation,
|
||||||
useGetMyApplicationsQuery,
|
useGetMyApplicationsQuery,
|
||||||
useGetMyLicensesQuery,
|
useGetMyLicensesQuery,
|
||||||
@@ -31,9 +32,17 @@ import {
|
|||||||
import { useCurrentProfile } from '@ema-platform/auth';
|
import { useCurrentProfile } from '@ema-platform/auth';
|
||||||
import { AdvancedTable, PageLoader, notify, useServerTable } from '@ema-platform/ui';
|
import { AdvancedTable, PageLoader, notify, useServerTable } from '@ema-platform/ui';
|
||||||
import { useDateDisplayer } from '@ema-platform/shared';
|
import { useDateDisplayer } from '@ema-platform/shared';
|
||||||
|
import { useApplicationPayment } from '../../payments/hooks/useApplicationPayment';
|
||||||
import { endorsementColumns } from './columns';
|
import { endorsementColumns } from './columns';
|
||||||
|
|
||||||
const ENDORSEMENT_TYPE_KEYS = ['ENDORSEMENT_COC', 'ENDORSEMENT_GOC'];
|
// ENDORSEMENT_SEAFARER covers CoC and GOC together and is the only type new
|
||||||
|
// applications file against; the other two stay listed so an application or
|
||||||
|
// licence filed before the switch keeps showing up here.
|
||||||
|
const ENDORSEMENT_TYPE_KEYS = [
|
||||||
|
'ENDORSEMENT_SEAFARER',
|
||||||
|
'ENDORSEMENT_COC',
|
||||||
|
'ENDORSEMENT_GOC',
|
||||||
|
];
|
||||||
|
|
||||||
function EligibilityItem({ ok, label }: { ok: boolean; label: string }) {
|
function EligibilityItem({ ok, label }: { ok: boolean; label: string }) {
|
||||||
return (
|
return (
|
||||||
@@ -74,6 +83,8 @@ export function EndorsementPage() {
|
|||||||
const showDate = useDateDisplayer();
|
const showDate = useDateDisplayer();
|
||||||
const localized = useLocalized();
|
const localized = useLocalized();
|
||||||
const [getCertificateUrl] = useGetCertificateUrlMutation();
|
const [getCertificateUrl] = useGetCertificateUrlMutation();
|
||||||
|
const { pay, isPaying } = useApplicationPayment();
|
||||||
|
const [bypassPayment, { isLoading: bypassing }] = useBypassPaymentMutation();
|
||||||
const issuedTable = useServerTable();
|
const issuedTable = useServerTable();
|
||||||
|
|
||||||
const registered =
|
const registered =
|
||||||
@@ -90,6 +101,22 @@ export function EndorsementPage() {
|
|||||||
);
|
);
|
||||||
const issuedPage = issuedTable.paginate(issued);
|
const issuedPage = issuedTable.paginate(issued);
|
||||||
|
|
||||||
|
async function handleBypass(applicationId: string) {
|
||||||
|
try {
|
||||||
|
const result = await bypassPayment(applicationId).unwrap();
|
||||||
|
notify.success(
|
||||||
|
result.certificateIssued
|
||||||
|
? t('endorsement.bypassIssued', 'Payment bypassed — the endorsement has been issued.')
|
||||||
|
: t('endorsement.bypassOk', {
|
||||||
|
defaultValue: 'Payment bypassed — application is now {{status}}.',
|
||||||
|
status: result.status.replace(/_/g, ' ').toLowerCase(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
notify.error(extractErrorMessage(err, t('endorsement.bypassFailed', 'Bypass failed')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function download(licenseId: string) {
|
async function download(licenseId: string) {
|
||||||
try {
|
try {
|
||||||
const result = await getCertificateUrl(licenseId).unwrap();
|
const result = await getCertificateUrl(licenseId).unwrap();
|
||||||
@@ -132,21 +159,13 @@ export function EndorsementPage() {
|
|||||||
/>
|
/>
|
||||||
</List>
|
</List>
|
||||||
</div>
|
</div>
|
||||||
<Stack gap="xs">
|
<Button
|
||||||
<Button
|
disabled={!registered}
|
||||||
rightSection={<IconArrowRight size={16} />}
|
rightSection={<IconArrowRight size={16} />}
|
||||||
onClick={() => navigate('/licensing/ENDORSEMENT_COC/apply')}
|
onClick={() => navigate('/licensing/ENDORSEMENT_SEAFARER/apply')}
|
||||||
>
|
>
|
||||||
{t('endorsement.endorseCoc', 'Endorse a CoC')}
|
{t('endorsement.apply', 'Apply for an endorsement')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
variant="light"
|
|
||||||
rightSection={<IconArrowRight size={16} />}
|
|
||||||
onClick={() => navigate('/licensing/ENDORSEMENT_GOC/apply')}
|
|
||||||
>
|
|
||||||
{t('endorsement.endorseGoc', 'Endorse a GOC')}
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Group>
|
</Group>
|
||||||
{!registered && (
|
{!registered && (
|
||||||
<Alert color="orange" mt="md" icon={<IconInfoCircle size={16} />}>
|
<Alert color="orange" mt="md" icon={<IconInfoCircle size={16} />}>
|
||||||
@@ -183,6 +202,36 @@ export function EndorsementPage() {
|
|||||||
<Badge color={STATUS_COLORS[app.status]}>
|
<Badge color={STATUS_COLORS[app.status]}>
|
||||||
{t(`applications.status.${app.status}`, STATUS_LABELS[app.status])}
|
{t(`applications.status.${app.status}`, STATUS_LABELS[app.status])}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{app.status === 'PAYMENT_PENDING' && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="compact-sm"
|
||||||
|
color="yellow"
|
||||||
|
loading={isPaying}
|
||||||
|
onClick={() => pay(app.id)}
|
||||||
|
>
|
||||||
|
{t('endorsement.pay', {
|
||||||
|
defaultValue: 'Pay {{amount}} {{currency}}',
|
||||||
|
amount: Number(app.feeAmount ?? 0).toLocaleString(),
|
||||||
|
currency: app.feeCurrency,
|
||||||
|
})}
|
||||||
|
</Button>
|
||||||
|
{/* ponytail: shown unconditionally for the testing
|
||||||
|
phase — the server still refuses it unless
|
||||||
|
ALLOW_PAYMENT_BYPASS is set and NODE_ENV is not
|
||||||
|
production. Re-gate on useGetPaymentCapabilitiesQuery
|
||||||
|
(like MyApplicationsPage) before prod. */}
|
||||||
|
<Button
|
||||||
|
size="compact-sm"
|
||||||
|
variant="default"
|
||||||
|
loading={bypassing}
|
||||||
|
onClick={() => handleBypass(app.id)}
|
||||||
|
title="Testing only — marks the fee paid"
|
||||||
|
>
|
||||||
|
{t('endorsement.bypass', 'Bypass payment')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
size="compact-sm"
|
size="compact-sm"
|
||||||
variant="light"
|
variant="light"
|
||||||
|
|||||||
@@ -356,6 +356,14 @@ export function LicenseApplicationPage() {
|
|||||||
[steps],
|
[steps],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// A section-level showWhen can remove a step while the wizard is open
|
||||||
|
// (ENDORSEMENT_SEAFARER's certificate sections follow the chosen scope).
|
||||||
|
// Clamp so `steps[active]` can never go out of bounds if a seed ever lets
|
||||||
|
// a later answer hide an earlier step.
|
||||||
|
useEffect(() => {
|
||||||
|
if (active > steps.length - 1) setActive(Math.max(0, steps.length - 1));
|
||||||
|
}, [active, steps.length]);
|
||||||
|
|
||||||
if (loadingConfig || !config || !appId || !application) {
|
if (loadingConfig || !config || !appId || !application) {
|
||||||
return <PageLoader label={t('licenseApplication.loading', 'Loading Application…')} height={400} />;
|
return <PageLoader label={t('licenseApplication.loading', 'Loading Application…')} height={400} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,10 @@ const MODE_FREE_TYPE_KEYS = [
|
|||||||
'VESSEL_OWNERSHIP_TRANSFER',
|
'VESSEL_OWNERSHIP_TRANSFER',
|
||||||
'CERTIFICATE_OF_COMPETENCY',
|
'CERTIFICATE_OF_COMPETENCY',
|
||||||
'CERTIFICATE_OF_PROFICIENCY',
|
'CERTIFICATE_OF_PROFICIENCY',
|
||||||
|
'ENDORSEMENT_SEAFARER',
|
||||||
|
// Retired by ENDORSEMENT_SEAFARER but kept mode-free: an in-flight
|
||||||
|
// application filed against one of these before the switch must still be
|
||||||
|
// reachable to view, correct or resubmit.
|
||||||
'ENDORSEMENT_COC',
|
'ENDORSEMENT_COC',
|
||||||
'ENDORSEMENT_GOC',
|
'ENDORSEMENT_GOC',
|
||||||
'PRE_WAIVER',
|
'PRE_WAIVER',
|
||||||
|
|||||||
@@ -16,10 +16,16 @@ import { OperationsFormContent } from "../../profile/components/OperationsFormCo
|
|||||||
* Seafarer goes to its own registration page, whose Identity Details step
|
* Seafarer goes to its own registration page, whose Identity Details step
|
||||||
* collects the profile answers itself — no detour via `/profile`. Seafarer
|
* collects the profile answers itself — no detour via `/profile`. Seafarer
|
||||||
* wins when both are ticked; the other form is one nav click away.
|
* wins when both are ticked; the other form is one nav click away.
|
||||||
|
*
|
||||||
|
* SEAFARER_REGISTRATION is listed before ENDORSEMENT_SEAFARER deliberately:
|
||||||
|
* `nextStepFor` takes the first key that matches, and an applicant who ticked
|
||||||
|
* both belongs in registration first — the endorsement application refuses
|
||||||
|
* submission until that registration is accepted.
|
||||||
*/
|
*/
|
||||||
const NEXT_STEP: Record<string, string> = {
|
const NEXT_STEP: Record<string, string> = {
|
||||||
SEAFARER_REGISTRATION: "/seafarer-registration",
|
SEAFARER_REGISTRATION: "/seafarer-registration",
|
||||||
VESSEL_REGISTRATION: "/licensing/VESSEL_REGISTRATION/apply",
|
VESSEL_REGISTRATION: "/vessel-registration",
|
||||||
|
ENDORSEMENT_SEAFARER: "/endorsements",
|
||||||
};
|
};
|
||||||
|
|
||||||
function nextStepFor(selectedKeys: string[]): string {
|
function nextStepFor(selectedKeys: string[]): string {
|
||||||
|
|||||||
@@ -25,15 +25,21 @@ import { notify, ModalFooter } from '@ema-platform/ui';
|
|||||||
import { useDateDisplayer } from '@ema-platform/shared';
|
import { useDateDisplayer } from '@ema-platform/shared';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registrations an applicant makes for themselves rather than for a company.
|
* Registrations and seafarer-only services an applicant declares for
|
||||||
|
* themselves rather than for a company.
|
||||||
*
|
*
|
||||||
* Named explicitly rather than inferred from `requiresOperatorMode: false`,
|
* Named explicitly rather than inferred from `requiresOperatorMode: false`,
|
||||||
* because that flag is also false for things nobody declares up front — a
|
* because that flag is also false for things nobody declares up front — a
|
||||||
* waiver is requested per shipment, not adopted as an identity.
|
* waiver is requested per shipment, not adopted as an identity.
|
||||||
|
* ENDORSEMENT_SEAFARER belongs here for the same reason as the two
|
||||||
|
* registrations: a seafarer requesting one is not declaring a logistics mode
|
||||||
|
* of operation, and without this key `accountTypeFor` below would fall the
|
||||||
|
* account through to the company-representative type instead of `SEAFARER`.
|
||||||
*/
|
*/
|
||||||
const PERSONAL_REGISTRATION_KEYS = [
|
const PERSONAL_REGISTRATION_KEYS = [
|
||||||
'SEAFARER_REGISTRATION',
|
'SEAFARER_REGISTRATION',
|
||||||
'VESSEL_REGISTRATION',
|
'VESSEL_REGISTRATION',
|
||||||
|
'ENDORSEMENT_SEAFARER',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,6 +62,7 @@ function accountTypeFor(keys: string[]): string | null {
|
|||||||
}
|
}
|
||||||
if (keys.includes('VESSEL_REGISTRATION')) return 'VESSEL_OWNER';
|
if (keys.includes('VESSEL_REGISTRATION')) return 'VESSEL_OWNER';
|
||||||
if (keys.includes('SEAFARER_REGISTRATION')) return 'SEAFARER';
|
if (keys.includes('SEAFARER_REGISTRATION')) return 'SEAFARER';
|
||||||
|
if (keys.includes('ENDORSEMENT_SEAFARER')) return 'SEAFARER';
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,7 +219,7 @@ export function OperationsFormContent({
|
|||||||
{personalOptions.length > 0 && (
|
{personalOptions.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Text size="xs" fw={600} c="dimmed" mt="sm" tt="uppercase">
|
<Text size="xs" fw={600} c="dimmed" mt="sm" tt="uppercase">
|
||||||
Registering as an individual or vessel owner
|
Registering or applying as an individual or vessel owner
|
||||||
</Text>
|
</Text>
|
||||||
{personalOptions.map((type) => (
|
{personalOptions.map((type) => (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
|
|||||||
@@ -373,7 +373,7 @@ export function SeafarerRegistrationPage() {
|
|||||||
{registration.status === 'APPROVED' && (
|
{registration.status === 'APPROVED' && (
|
||||||
<Alert color="teal" icon={<IconCheck size={16} />} title="Registered" mb="md">
|
<Alert color="teal" icon={<IconCheck size={16} />} title="Registered" mb="md">
|
||||||
You are a registered seafarer. Your seafarer number is <b>{registration.seafarerNumber}</b>.
|
You are a registered seafarer. Your seafarer number is <b>{registration.seafarerNumber}</b>.
|
||||||
Your Seaman Book and Basic Training Certificate applications have been opened for you.
|
You can now apply for a certificate endorsement from the Endorsement Seafarer page.
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
{registration.status === 'REJECTED' && (
|
{registration.status === 'REJECTED' && (
|
||||||
|
|||||||
@@ -12,7 +12,13 @@ import {
|
|||||||
/** Columns for the applicant's in-flight vessel registration applications. */
|
/** Columns for the applicant's in-flight vessel registration applications. */
|
||||||
export function inFlightColumns(
|
export function inFlightColumns(
|
||||||
t: TFunction,
|
t: TFunction,
|
||||||
deps: { onOpen: (app: LicenseApplication) => void },
|
deps: {
|
||||||
|
onOpen: (app: LicenseApplication) => void;
|
||||||
|
onPay: (app: LicenseApplication) => void;
|
||||||
|
onBypass: (app: LicenseApplication) => void;
|
||||||
|
isPaying: boolean;
|
||||||
|
bypassing: boolean;
|
||||||
|
},
|
||||||
): AdvancedColumn<LicenseApplication>[] {
|
): AdvancedColumn<LicenseApplication>[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -38,15 +44,50 @@ export function inFlightColumns(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: t('applications.table.progress'),
|
header: t('applications.table.progress'),
|
||||||
size: 140,
|
size: 300,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => {
|
||||||
<Progress
|
const app = row.original;
|
||||||
value={STATUS_PROGRESS[row.original.status]}
|
return (
|
||||||
color={STATUS_COLORS[row.original.status]}
|
<Group gap="xs" wrap="nowrap">
|
||||||
size="sm"
|
<Progress
|
||||||
radius="xl"
|
value={STATUS_PROGRESS[app.status]}
|
||||||
/>
|
color={STATUS_COLORS[app.status]}
|
||||||
),
|
size="sm"
|
||||||
|
radius="xl"
|
||||||
|
style={{ flex: 1, minWidth: 60 }}
|
||||||
|
/>
|
||||||
|
{/* The fee stops the registration dead, so the payment action sits
|
||||||
|
on the bar rather than being hidden behind View. */}
|
||||||
|
{app.status === 'PAYMENT_PENDING' && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="compact-sm"
|
||||||
|
color="yellow"
|
||||||
|
loading={deps.isPaying}
|
||||||
|
onClick={() => deps.onPay(app)}
|
||||||
|
>
|
||||||
|
{t('applications.actions.pay', {
|
||||||
|
amount: Number(app.feeAmount ?? 0).toLocaleString(),
|
||||||
|
currency: app.feeCurrency,
|
||||||
|
})}
|
||||||
|
</Button>
|
||||||
|
{/* ponytail: shown unconditionally — the licensing page hides
|
||||||
|
this behind the API's bypassEnabled capability flag, which
|
||||||
|
is off here. Re-gate on capabilities before prod. */}
|
||||||
|
<Button
|
||||||
|
size="compact-sm"
|
||||||
|
variant="default"
|
||||||
|
loading={deps.bypassing}
|
||||||
|
onClick={() => deps.onBypass(app)}
|
||||||
|
title="Testing only — marks the fee paid"
|
||||||
|
>
|
||||||
|
{t('applications.actions.bypass')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: '',
|
header: '',
|
||||||
|
|||||||
@@ -30,11 +30,15 @@ import {
|
|||||||
import { StatusBadge, AdvancedTable } from '@ema-platform/ui';
|
import { StatusBadge, AdvancedTable } from '@ema-platform/ui';
|
||||||
import { inFlightColumns } from '../inFlightColumns';
|
import { inFlightColumns } from '../inFlightColumns';
|
||||||
import {
|
import {
|
||||||
|
extractErrorMessage,
|
||||||
TERMINAL_STATUSES,
|
TERMINAL_STATUSES,
|
||||||
useApiMutation,
|
useApiMutation,
|
||||||
|
useBypassPaymentMutation,
|
||||||
useGetMyApplicationsQuery,
|
useGetMyApplicationsQuery,
|
||||||
} from '@ema-platform/api';
|
} from '@ema-platform/api';
|
||||||
|
import { notifications } from '@mantine/notifications';
|
||||||
import { authStorage } from '@ema-platform/auth';
|
import { authStorage } from '@ema-platform/auth';
|
||||||
|
import { useApplicationPayment } from '../../payments/hooks/useApplicationPayment';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
@@ -132,6 +136,8 @@ export function VesselRegistrationPage() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { data: applications, isFetching, refetch } = useGetMyApplicationsQuery();
|
const { data: applications, isFetching, refetch } = useGetMyApplicationsQuery();
|
||||||
|
const { pay, isPaying } = useApplicationPayment();
|
||||||
|
const [bypassPayment, { isLoading: bypassing }] = useBypassPaymentMutation();
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
const [registration, setRegistration] = useState<VesselRegistration | null>(null);
|
const [registration, setRegistration] = useState<VesselRegistration | null>(null);
|
||||||
const [fetchTrigger] = useApiMutation<VesselRegistration>();
|
const [fetchTrigger] = useApiMutation<VesselRegistration>();
|
||||||
@@ -155,9 +161,32 @@ export function VesselRegistrationPage() {
|
|||||||
!TERMINAL_STATUSES.includes(app.status),
|
!TERMINAL_STATUSES.includes(app.status),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
async function handleBypass(applicationId: string) {
|
||||||
|
try {
|
||||||
|
const result = await bypassPayment(applicationId).unwrap();
|
||||||
|
notifications.show({
|
||||||
|
color: 'teal',
|
||||||
|
title: 'Payment bypassed',
|
||||||
|
message: `Application is now ${result.status.replace(/_/g, ' ').toLowerCase()}.`,
|
||||||
|
});
|
||||||
|
refetch();
|
||||||
|
} catch (err) {
|
||||||
|
notifications.show({
|
||||||
|
color: 'red',
|
||||||
|
title: 'Bypass failed',
|
||||||
|
message: extractErrorMessage(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const columns = inFlightColumns(t, {
|
const columns = inFlightColumns(t, {
|
||||||
onOpen: (app) =>
|
onOpen: (app) =>
|
||||||
navigate(`/licensing/${REGISTRATION_TYPE_KEY}/applications/${app.id}`),
|
navigate(`/licensing/${REGISTRATION_TYPE_KEY}/applications/${app.id}`),
|
||||||
|
// Paying leaves the SPA for Telebirr — a provider hand-off, not a route change.
|
||||||
|
onPay: (app) => pay(app.id),
|
||||||
|
onBypass: (app) => handleBypass(app.id),
|
||||||
|
isPaying,
|
||||||
|
bypassing,
|
||||||
});
|
});
|
||||||
|
|
||||||
const certs = registration?.category === 'Sea-going Vessel (International)'
|
const certs = registration?.category === 'Sea-going Vessel (International)'
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export const am: Translations = {
|
|||||||
certificates: 'የምስክር ወረቀቶች',
|
certificates: 'የምስክር ወረቀቶች',
|
||||||
seamanBook: 'የመርከበኛ መጽሐፍ እና BTC',
|
seamanBook: 'የመርከበኛ መጽሐፍ እና BTC',
|
||||||
btc: 'መሠረታዊ ሥልጠና ምስክር ወረቀት',
|
btc: 'መሠረታዊ ሥልጠና ምስክር ወረቀት',
|
||||||
endorsements: 'ማረጋገጫዎች',
|
endorsements: 'የባህረኛ ማስተያየት',
|
||||||
vesselRegistrations: 'የመርከብ ምዝገባ',
|
vesselRegistrations: 'የመርከብ ምዝገባ',
|
||||||
vesselTransfers: 'የመርከብ ባለቤትነት ዝውውር',
|
vesselTransfers: 'የመርከብ ባለቤትነት ዝውውር',
|
||||||
documents: 'ሰነዶቼ',
|
documents: 'ሰነዶቼ',
|
||||||
@@ -1008,8 +1008,7 @@ export const am: Translations = {
|
|||||||
registered: "የተመዘገበ መርከበኛ ({{number}})",
|
registered: "የተመዘገበ መርከበኛ ({{number}})",
|
||||||
registrationRequired: "ንቁ የመርከበኛ ምዝገባ ያስፈልጋል",
|
registrationRequired: "ንቁ የመርከበኛ ምዝገባ ያስፈልጋል",
|
||||||
},
|
},
|
||||||
endorseCoc: "CoC ያረጋግጡ",
|
apply: "ለማስተያየት ያመልክቱ",
|
||||||
endorseGoc: "GOC ያረጋግጡ",
|
|
||||||
registrationNotice: {
|
registrationNotice: {
|
||||||
prefix: "መጀመሪያ የ",
|
prefix: "መጀመሪያ የ",
|
||||||
link: "መርከበኛ ምዝገባዎን",
|
link: "መርከበኛ ምዝገባዎን",
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export const en = {
|
|||||||
certificates: 'Certificates',
|
certificates: 'Certificates',
|
||||||
seamanBook: 'SeamanBook and BTC',
|
seamanBook: 'SeamanBook and BTC',
|
||||||
btc: 'Basic Training Certificate',
|
btc: 'Basic Training Certificate',
|
||||||
endorsements: 'Endorsements',
|
endorsements: 'Endorsement Seafarer',
|
||||||
vesselRegistrations: 'Vessel Registration',
|
vesselRegistrations: 'Vessel Registration',
|
||||||
vesselTransfers: 'Vessel Transfers',
|
vesselTransfers: 'Vessel Transfers',
|
||||||
documents: 'My Documents',
|
documents: 'My Documents',
|
||||||
@@ -1010,8 +1010,7 @@ export const en = {
|
|||||||
registered: 'Registered seafarer ({{number}})',
|
registered: 'Registered seafarer ({{number}})',
|
||||||
registrationRequired: 'Active seafarer registration required',
|
registrationRequired: 'Active seafarer registration required',
|
||||||
},
|
},
|
||||||
endorseCoc: 'Endorse a CoC',
|
apply: 'Apply for an endorsement',
|
||||||
endorseGoc: 'Endorse a GOC',
|
|
||||||
registrationNotice: {
|
registrationNotice: {
|
||||||
prefix: 'Complete your',
|
prefix: 'Complete your',
|
||||||
link: 'seafarer registration',
|
link: 'seafarer registration',
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ const NAV_SECTIONS: { label?: string; items: PortalNavItem[] }[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: "/endorsements",
|
to: "/endorsements",
|
||||||
label: "Endorsements",
|
label: "Endorsement Seafarer",
|
||||||
i18nKey: "nav.endorsements",
|
i18nKey: "nav.endorsements",
|
||||||
icon: IconRubberStamp,
|
icon: IconRubberStamp,
|
||||||
permissions: [P.VIEW_OWN_CERTIFICATES],
|
permissions: [P.VIEW_OWN_CERTIFICATES],
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { resolveTokenFromStorage } from '../../session';
|
|||||||
import type {
|
import type {
|
||||||
Bilingual,
|
Bilingual,
|
||||||
FamilyKind,
|
FamilyKind,
|
||||||
|
FieldCondition,
|
||||||
FormFieldConfig,
|
FormFieldConfig,
|
||||||
FormSectionConfig,
|
FormSectionConfig,
|
||||||
LicenseApplication,
|
LicenseApplication,
|
||||||
@@ -179,6 +180,7 @@ export const APPLICANT_NAME_TYPE_KEYS = [
|
|||||||
'CERTIFICATE_OF_PROFICIENCY',
|
'CERTIFICATE_OF_PROFICIENCY',
|
||||||
'VESSEL_REGISTRATION',
|
'VESSEL_REGISTRATION',
|
||||||
'VESSEL_OWNERSHIP_TRANSFER',
|
'VESSEL_OWNERSHIP_TRANSFER',
|
||||||
|
'ENDORSEMENT_SEAFARER',
|
||||||
'ENDORSEMENT_COC',
|
'ENDORSEMENT_COC',
|
||||||
'ENDORSEMENT_GOC',
|
'ENDORSEMENT_GOC',
|
||||||
];
|
];
|
||||||
@@ -190,6 +192,7 @@ const FAMILY_KIND_BY_KEY: Partial<Record<string, FamilyKind>> = {
|
|||||||
BTC_BASIC_TRAINING: 'CERTIFICATE',
|
BTC_BASIC_TRAINING: 'CERTIFICATE',
|
||||||
CERTIFICATE_OF_COMPETENCY: 'CERTIFICATE',
|
CERTIFICATE_OF_COMPETENCY: 'CERTIFICATE',
|
||||||
CERTIFICATE_OF_PROFICIENCY: 'CERTIFICATE',
|
CERTIFICATE_OF_PROFICIENCY: 'CERTIFICATE',
|
||||||
|
ENDORSEMENT_SEAFARER: 'CERTIFICATE',
|
||||||
ENDORSEMENT_COC: 'CERTIFICATE',
|
ENDORSEMENT_COC: 'CERTIFICATE',
|
||||||
ENDORSEMENT_GOC: 'CERTIFICATE',
|
ENDORSEMENT_GOC: 'CERTIFICATE',
|
||||||
FREIGHT_FORWARDER: 'LOGISTICS_LICENSE',
|
FREIGHT_FORWARDER: 'LOGISTICS_LICENSE',
|
||||||
@@ -557,7 +560,14 @@ export function validateSections(
|
|||||||
return errors;
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Evaluates a config condition against the current form answers. */
|
/**
|
||||||
|
* Evaluates a config condition against the current form answers.
|
||||||
|
*
|
||||||
|
* Mirrors the server's `ApplicationValidationService.conditionHolds` —
|
||||||
|
* `anyOf` holds when any listed sub-condition holds, needed for an answer
|
||||||
|
* that can live on one of several mutually-exclusive fields (e.g. a CoP rank
|
||||||
|
* split by department).
|
||||||
|
*/
|
||||||
interface ConditionLike {
|
interface ConditionLike {
|
||||||
field?: string;
|
field?: string;
|
||||||
equals?: unknown;
|
equals?: unknown;
|
||||||
@@ -569,7 +579,7 @@ interface ConditionLike {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function conditionHolds(
|
export function conditionHolds(
|
||||||
condition: ConditionLike | undefined | null,
|
condition: FieldCondition | undefined | null,
|
||||||
formData: Record<string, Record<string, unknown>>,
|
formData: Record<string, Record<string, unknown>>,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!condition) return true;
|
if (!condition) return true;
|
||||||
|
|||||||
@@ -74,9 +74,9 @@ export interface FieldCondition {
|
|||||||
in?: (string | number)[];
|
in?: (string | number)[];
|
||||||
isSet?: boolean;
|
isSet?: boolean;
|
||||||
/**
|
/**
|
||||||
* Holds when ANY listed condition holds — for a value that can live on one
|
* Alternative to a single-field check: holds when ANY listed condition
|
||||||
* of several mutually-exclusive fields (e.g. a rank split by department).
|
* holds. `field`/`equals`/etc are ignored when this is present. Mirrors
|
||||||
* `field`/`equals`/etc are ignored when this is present.
|
* the server's `FieldCondition` (form-schema.type.ts).
|
||||||
*/
|
*/
|
||||||
anyOf?: FieldCondition[];
|
anyOf?: FieldCondition[];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user