Merge pull request #29 from Tria-plc/WorkflowChange

Workflow change
This commit is contained in:
Nati Nigussie
2026-08-26 15:36:46 +03:00
committed by GitHub
24 changed files with 315 additions and 76 deletions

View File

@@ -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;
} }

View File

@@ -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,

View File

@@ -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: {

View File

@@ -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

View File

@@ -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":

View File

@@ -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);
});
});

View File

@@ -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');
} }

View File

@@ -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: "የመርከብ ባለቤትነት ዝውውር",

View File

@@ -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',

View File

@@ -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] },
], ],

View File

@@ -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 />) },

View File

@@ -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"

View File

@@ -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} />;
} }

View File

@@ -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',

View File

@@ -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 {

View File

@@ -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

View File

@@ -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' && (

View File

@@ -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 }) => {
const app = row.original;
return (
<Group gap="xs" wrap="nowrap">
<Progress <Progress
value={STATUS_PROGRESS[row.original.status]} value={STATUS_PROGRESS[app.status]}
color={STATUS_COLORS[row.original.status]} color={STATUS_COLORS[app.status]}
size="sm" size="sm"
radius="xl" 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: '',

View File

@@ -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)'

View File

@@ -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: "መርከበኛ ምዝገባዎን",

View File

@@ -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',

View File

@@ -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],

View File

@@ -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;

View File

@@ -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[];
} }