diff --git a/apps/backoffice/src/app/features/license-review/config/actions.ts b/apps/backoffice/src/app/features/license-review/config/actions.ts
index 8511b7b29..a5ac52faa 100644
--- a/apps/backoffice/src/app/features/license-review/config/actions.ts
+++ b/apps/backoffice/src/app/features/license-review/config/actions.ts
@@ -28,6 +28,7 @@ export type ActionId =
| 'complete-review'
| 'approve-documents'
| 'schedule-inspection'
+ | 'reschedule-inspection'
| 'record-inspection'
| 'final-approve'
| 'request-adjustment'
@@ -200,6 +201,17 @@ export const ACTIONS: ActionDefinition[] = [
permissions: ['can:create:inspection'],
emphasis: 'filled',
},
+ {
+ id: 'reschedule-inspection',
+ tier: 'primary',
+ labelKey: 'review.actions.rescheduleInspection',
+ from: ['INSPECTION_PENDING', 'INSPECTION_FAILED'],
+ // Either permission: the team leader who booked the visit holds CREATE,
+ // the inspector who has to attend holds UPDATE, and both have a reason to
+ // move it. The server guards the same pair.
+ permissions: ['can:create:inspection', 'can:update:inspection'],
+ emphasis: 'light',
+ },
{
id: 'record-inspection',
tier: 'primary',
@@ -441,6 +453,12 @@ export function resolveActions(ctx: ResolveContext): ResolvedAction[] {
// one applies depends on whether an inspection is already booked.
if (action.id === 'schedule-inspection' && ctx.hasPendingInspection) return [];
if (action.id === 'record-inspection' && !ctx.hasPendingInspection) return [];
+ // The mirror of the scheduling gate: there is nothing to move until a
+ // visit is booked, and once one is, moving it is the officer's only
+ // option until the day arrives.
+ if (action.id === 'reschedule-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
diff --git a/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx b/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx
index 3c5b8f2f1..cfb2bf6ee 100644
--- a/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx
+++ b/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx
@@ -32,6 +32,7 @@ import {
IconLayoutSidebarRightCollapse,
IconLayoutSidebarRightExpand,
IconPaperclip,
+ IconPencil,
IconQuestionMark,
IconX,
} from "@tabler/icons-react";
@@ -70,6 +71,7 @@ import {
useRequestAdjustmentMutation,
useResumeApplicationMutation,
useScheduleInspectionMutation,
+ useRescheduleInspectionMutation,
useGetCertificateUrlForOfficerMutation,
uploadDocument,
type RemarkTargetType,
@@ -218,6 +220,7 @@ export function LicenseReviewPage() {
const [finalApprove] = useFinalApproveMutation();
const [rejectApplication] = useRejectApplicationMutation();
const [scheduleInspection] = useScheduleInspectionMutation();
+ const [rescheduleInspection] = useRescheduleInspectionMutation();
const [recordResult] = useRecordInspectionResultMutation();
const [confirmPayment] = useConfirmPaymentMutation();
const [scheduleIssuance] = useScheduleIssuanceMutation();
@@ -261,6 +264,9 @@ export function LicenseReviewPage() {
const [inspectionTimeSlot, setInspectionTimeSlot] = useState<"MORNING" | "AFTERNOON">(
"MORNING",
);
+ /** The booking modal moves an existing visit rather than creating one. */
+ const [rescheduling, setRescheduling] = useState(false);
+ const [rescheduleReason, setRescheduleReason] = useState("");
const [issuanceOpen, setIssuanceOpen] = useState(false);
const [issuanceDate, setIssuanceDate] = useState("");
const [resultOpen, setResultOpen] = useState(false);
@@ -311,10 +317,14 @@ export function LicenseReviewPage() {
const pendingInspection = inspections.find((i) => i.status === 'SCHEDULED');
// A visit cannot have an outcome before it happens — mirror of the server's
- // inspection_not_yet_due guard, compared instant-to-instant.
+ // inspection_not_yet_due guard. The column holds a calendar day, so the
+ // comparison is between day strings in the authority's timezone: parsing
+ // "2026-08-28" as a Date would read it as UTC midnight, i.e. 03:00 in Addis.
const inspectionNotYetDue = Boolean(
pendingInspection?.scheduledDate &&
- new Date(pendingInspection.scheduledDate) > new Date(),
+ new Intl.DateTimeFormat("en-CA", { timeZone: "Africa/Addis_Ababa" }).format(
+ new Date(),
+ ) < pendingInspection.scheduledDate.slice(0, 10),
);
const { data: findingsEvidence = [], refetch: refetchFindingsEvidence } =
@@ -558,12 +568,29 @@ export function LicenseReviewPage() {
}
}
+ /** Opens the booking modal seeded with the visit already on the books. */
+ function openReschedule() {
+ if (!pendingInspection) return;
+ setRescheduling(true);
+ setInspectionDate(pendingInspection.scheduledDate ?? "");
+ setInspectionTimeSlot(pendingInspection.timeSlot ?? "MORNING");
+ setRescheduleReason("");
+ setInspectionOpen(true);
+ }
+
/** Actions with their own dedicated form open that; the rest confirm. */
function handleAction(action: ResolvedAction) {
switch (action.id) {
case "schedule-inspection":
+ setRescheduling(false);
+ setInspectionDate("");
+ setInspectionTimeSlot("MORNING");
+ setRescheduleReason("");
setInspectionOpen(true);
return;
+ case "reschedule-inspection":
+ openReschedule();
+ return;
case "schedule-issuance":
setIssuanceOpen(true);
return;
@@ -1246,21 +1273,48 @@ export function LicenseReviewPage() {
)}
-
- {inspection.result === "PASSED"
- ? t("review.passed", "Passed")
- : inspection.result === "FAILED"
- ? t("review.failed", "Failed")
- : t(
- `review.inspectionStatus.${inspection.status}`,
- inspection.status,
+
+ {inspection.status === "SCHEDULED" &&
+ inspection.id === pendingInspection?.id &&
+ can([
+ "can:create:inspection",
+ "can:update:inspection",
+ ]) && (
+
+ >
+
+
+
+
+ )}
+
+ {inspection.result === "PASSED"
+ ? t("review.passed", "Passed")
+ : inspection.result === "FAILED"
+ ? t("review.failed", "Failed")
+ : t(
+ `review.inspectionStatus.${inspection.status}`,
+ inspection.status,
+ )}
+
+
))}
@@ -1430,7 +1484,11 @@ export function LicenseReviewPage() {
setInspectionOpen(false)}
- title={t("review.actions.scheduleInspection", "Schedule inspection")}
+ title={
+ rescheduling
+ ? t("review.actions.rescheduleInspection", "Reschedule inspection")
+ : t("review.actions.scheduleInspection", "Schedule inspection")
+ }
>
+ {rescheduling && (
+
@@ -1492,34 +1586,36 @@ export function LicenseReviewPage() {
onChange={setIssuanceDate}
/>
+ {/* Span-wrapped like DecisionBar's ActionButton — Mantine strips
+ pointer events from a disabled control, and a disabled button
+ must still say why. */}
-
-
+
+
+ run(
+ async () => {
+ await scheduleIssuance({
+ id,
+ scheduledDate: issuanceDate,
+ }).unwrap();
+ setIssuanceOpen(false);
+ },
+ t("review.done.scheduleIssuance", "Pickup scheduled"),
+ )
+ }
+ >
+
+
-
- run(
- async () => {
- await scheduleIssuance({
- id,
- scheduledDate: issuanceDate,
- }).unwrap();
- setIssuanceOpen(false);
- },
- t("review.done.scheduleIssuance", "Pickup scheduled"),
- )
- }
- >
-
-
diff --git a/apps/backoffice/src/app/features/seafarer-registry/pages/SeafarerRegistryPage.tsx b/apps/backoffice/src/app/features/seafarer-registry/pages/SeafarerRegistryPage.tsx
index fc9349789..9ddebf0a0 100644
--- a/apps/backoffice/src/app/features/seafarer-registry/pages/SeafarerRegistryPage.tsx
+++ b/apps/backoffice/src/app/features/seafarer-registry/pages/SeafarerRegistryPage.tsx
@@ -184,7 +184,7 @@ export function SeafarerRegistryPage() {
{/* Search + filters */}
-
+
{filtered.length} seafarer(s) found
+
@@ -278,6 +279,7 @@ export function SeafarerRegistryPage() {
)}
+
setSelected(null)} />
diff --git a/apps/backoffice/src/app/i18n/locales/am.ts b/apps/backoffice/src/app/i18n/locales/am.ts
index 3f03fe1c8..86575519e 100644
--- a/apps/backoffice/src/app/i18n/locales/am.ts
+++ b/apps/backoffice/src/app/i18n/locales/am.ts
@@ -988,6 +988,9 @@ export const am: Translations = {
morning: "ጠዋት",
afternoon: "ከሰዓት በኋላ",
schedule: "ያዝ",
+ reschedule: "አዛውር",
+ rescheduleReason: "ለምን ይዛወራል?",
+ rescheduleReasonHint: "በኦዲት መዝገብ ውስጥ ተይዞ ለአመልካቹ ይላካል።",
pickDate: "መጀመሪያ ቀን ይምረጡ",
passed: "አልፏል",
failed: "ወድቋል",
@@ -1024,6 +1027,7 @@ export const am: Translations = {
completeReview: "ግምገማ አጠናቅቅ",
approveDocuments: "ሰነዶችን አጽድቅ",
scheduleInspection: "ምርመራ ያዝ",
+ rescheduleInspection: "ምርመራ አዛውር",
recordInspection: "የምርመራ ውጤት መዝግብ",
finalApprove: "አጽድቅ እና ስጥ",
requestAdjustment: "ማስተካከያ ጠይቅ",
@@ -1177,6 +1181,7 @@ export const am: Translations = {
assign: "እንደገና ተመድቧል",
assignReviewer: "ግምገማ ተመድቧል",
scheduled: "ምርመራ ተይዟል",
+ rescheduled: "ምርመራ ተዛውሯል",
inspectionPassed: "ምርመራ አልፏል",
inspectionFailed: "ምርመራ ወድቋል",
},
diff --git a/apps/backoffice/src/app/i18n/locales/en.ts b/apps/backoffice/src/app/i18n/locales/en.ts
index 748d1a9ff..093b55a88 100644
--- a/apps/backoffice/src/app/i18n/locales/en.ts
+++ b/apps/backoffice/src/app/i18n/locales/en.ts
@@ -997,6 +997,9 @@ export const en = {
morning: 'Morning',
afternoon: 'Afternoon',
schedule: 'Schedule',
+ reschedule: 'Reschedule',
+ rescheduleReason: 'Why is it moving?',
+ rescheduleReasonHint: 'Kept in the audit trail and sent to the applicant.',
pickDate: 'Pick a date first',
passed: 'Passed',
failed: 'Failed',
@@ -1033,6 +1036,7 @@ export const en = {
completeReview: 'Complete review',
approveDocuments: 'Approve documents',
scheduleInspection: 'Schedule inspection',
+ rescheduleInspection: 'Reschedule inspection',
recordInspection: 'Record inspection result',
finalApprove: 'Approve & issue',
requestAdjustment: 'Request adjustment',
@@ -1183,6 +1187,7 @@ export const en = {
assign: 'Reassigned',
assignReviewer: 'Review assigned',
scheduled: 'Inspection scheduled',
+ rescheduled: 'Inspection rescheduled',
inspectionPassed: 'Inspection passed',
inspectionFailed: 'Inspection failed',
},
diff --git a/apps/backoffice/vite.config.mts b/apps/backoffice/vite.config.mts
index 97ed5cae3..355a26fdf 100644
--- a/apps/backoffice/vite.config.mts
+++ b/apps/backoffice/vite.config.mts
@@ -42,14 +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'],
- // },
+ // Unit tests for the pure helpers behind a screen (formatters, URL state,
+ // queue views). 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', 'src/**/*.test.ts'],
+ reporters: ['default'],
+ },
});
diff --git a/apps/portal/src/app/features/licensing/components/DocumentSlots.tsx b/apps/portal/src/app/features/licensing/components/DocumentSlots.tsx
index b0fb505ed..06a137b46 100644
--- a/apps/portal/src/app/features/licensing/components/DocumentSlots.tsx
+++ b/apps/portal/src/app/features/licensing/components/DocumentSlots.tsx
@@ -37,6 +37,12 @@ interface Props {
flagged?: Record;
/** When set, only flagged slots accept a new upload. */
restrictToFlagged?: boolean;
+ /**
+ * Requirement keys opened because a flagged section drives their condition —
+ * a category correction can make documents newly required, and those have to
+ * be uploadable even though the officer flagged no document.
+ */
+ alsoUnlocked?: string[];
onUploaded: () => void;
readOnly?: boolean;
}
@@ -55,6 +61,7 @@ export function DocumentSlots({
ownerId,
flagged = {},
restrictToFlagged = false,
+ alsoUnlocked = [],
onUploaded,
readOnly,
}: Props) {
@@ -106,7 +113,11 @@ export function DocumentSlots({
const uploaded = Boolean(existing?.files?.length);
const fileUrl = existing?.files?.[0]?.url;
const flagRemark = flagged[requirement.key];
- const locked = readOnly || (restrictToFlagged && !flagRemark);
+ const locked =
+ readOnly ||
+ (restrictToFlagged &&
+ !flagRemark &&
+ !alsoUnlocked.includes(requirement.key));
return (
- {expired ? t('licensing.card.expired') : license.status}
+ {/* The raw enum was rendered here, so an Amharic page showed
+ "SUSPENDED" among otherwise translated text. */}
+ {expired
+ ? t('licensing.card.expired')
+ : t(`licensing.card.status.${license.status}`, {
+ defaultValue: license.status,
+ })}
@@ -113,8 +124,19 @@ export function LicenseCard({
{expired
? t('licensing.card.expiredOn', { date: showDate(license.expiryDate) })
- : t('licensing.card.validUntil', { date: showDate(license.expiryDate) })}
+ : current
+ ? t('licensing.card.validUntil', { date: showDate(license.expiryDate) })
+ : t(`licensing.card.status.${license.status}`, {
+ defaultValue: license.status,
+ })}
+ {/* Why it stopped being current. The API returns it; the card threw
+ it away, leaving the holder to guess. */}
+ {!current && license.statusReason && (
+
+ {t('licensing.card.statusReason', { reason: license.statusReason })}
+
+ )}
= {
CARGO_FREIGHT: IconBuildingWarehouse,
SHIPPING_AGENCY: IconShip,
INVESTMENT: IconTrendingUp,
- // The three below are filtered out of this catalogue today
- // (requiresOperatorMode is false for all of them), and are listed only so
- // the record stays total if that ever changes.
+ // The three below appear only when the applicant has declared a licence
+ // type in them (see the family filter below); listed here so the record
+ // stays total either way.
MARITIME_PERSONNEL: IconShip,
VESSEL_SERVICES: IconAnchor,
WAIVER_SERVICES: IconShieldOff,
@@ -81,15 +83,19 @@ export function LicenseCatalogue() {
const { groups, orphans } = useMemo(() => {
const active = (types?.items ?? [])
.filter((t) => t.isActive)
- // Logistics licences only: this is the operator catalogue, not the
- // seafarer certificate or vessel/seafarer document catalogue — those
- // have their own entry points. `familyKind` is the real data-model
- // classification (set on the type at seed time); `requiresOperatorMode`
- // was the proxy this used before that column existed and happened to
- // agree for every type seeded so far, but a type can only be trusted to
- // stay in sync with the catalogue it belongs in if the catalogue reads
- // its actual family instead of a flag with a different purpose.
- .filter((t) => t.familyKind === 'LOGISTICS_LICENSE')
+ // The logistics family, plus whatever this applicant actually declared.
+ //
+ // `familyKind` is the real data-model classification and is what keeps
+ // browse-all to the operator catalogue rather than every certificate and
+ // document type in the system. But it is not what decides eligibility:
+ // the Operations tab also offers the personal registrations (seafarer,
+ // vessel) and the seafarer endorsement, which are DOCUMENT/CERTIFICATE
+ // family, so an applicant who declared one of those was shown an empty
+ // catalogue — allowed to file, and offered nothing to file. Each of
+ // those keys already has an entry point at `/licensing//apply`
+ // (a router redirect for SEAFARER_REGISTRATION and SEAMAN_BOOK, the
+ // generic wizard for the rest), so the card leads somewhere real.
+ .filter((t) => t.familyKind === 'LOGISTICS_LICENSE' || declared.has(t.id))
// Only what the applicant operates as. The server enforces the same rule
// on create; this is what stops them starting an application they will
// be refused at the end of.
@@ -273,8 +279,8 @@ function LicenseTypeCard({
withBorder
radius="md"
padding="md"
- style={{ cursor: 'pointer', height: '100%' }}
- onClick={() => onSelect(type)}
+ style={{ cursor: canApply ? 'pointer' : 'default', height: '100%' }}
+ onClick={canApply ? () => onSelect(type) : undefined}
>
@@ -282,11 +288,19 @@ function LicenseTypeCard({
{localized(type.name)}
-
+ {canApply ? (
+
+ ) : (
+
+ )}
{type.description && (
@@ -325,13 +339,45 @@ function LicenseTypeCard({
mt="sm"
size="xs"
variant="light"
- color={canApply ? undefined : 'gray'}
+ disabled={!canApply}
rightSection={}
>
- {canApply
- ? t('licensing.catalogue.startApplication')
- : t('licensing.catalogue.addToOperations')}
+ {t('licensing.catalogue.startApplication')}
+ {/* A disabled button on its own only says "no". This says why, and
+ where to go about it — the licence is offered against a declared
+ mode of operation, and the server refuses a create for one the
+ applicant has not declared. Called out rather than set in dimmed
+ small print: it is the only thing on a locked card the applicant
+ can act on. */}
+ {!canApply && (
+
+
+ {t('licensing.catalogue.lockedHint')}
+
+ {
+ // The card is inert while locked, but the anchor inside it
+ // must not re-trigger anything if that ever changes.
+ event.stopPropagation();
+ onSelect(type);
+ }}
+ >
+ {t('licensing.catalogue.addToOperations')}
+
+
+ )}
diff --git a/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx b/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx
index ac497f5f7..19b83b655 100644
--- a/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx
+++ b/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx
@@ -32,6 +32,8 @@ import { useTranslation } from "react-i18next";
import {
buildWizardSteps,
conditionHolds,
+ conditionSections,
+ sectionsDependingOn,
extractErrorMessage,
extractValidationIssues,
useLocalized,
@@ -361,8 +363,34 @@ export function LicenseApplicationPage() {
// filed.
const roundIsItemised = isAdjusting && roundRemarks.length > 0;
+ // An answer the officer flagged can decide which fields *other* sections
+ // require — the vessel category is the live example. Freeze those and the
+ // applicant is shown newly-required fields they cannot fill, and cannot
+ // resubmit; the server unlocks them the same way.
+ const cascadeUnlocked = useMemo(
+ () =>
+ sectionsDependingOn(
+ config?.licenseType?.formSchema?.sections ?? [],
+ new Set(Object.keys(flaggedSections)),
+ ),
+ [config, flaggedSections],
+ );
+ const unlockedDocuments = useMemo(
+ () =>
+ (config?.documentRequirements ?? [])
+ .filter((requirement) =>
+ conditionSections(requirement.conditionExpression).some(
+ (sectionKey) => sectionKey in flaggedSections,
+ ),
+ )
+ .map((requirement) => requirement.key),
+ [config, flaggedSections],
+ );
+
const isSectionLocked = (sectionKey: string) =>
- roundIsItemised && !flaggedSections[sectionKey];
+ roundIsItemised &&
+ !flaggedSections[sectionKey] &&
+ !cascadeUnlocked.has(sectionKey);
const staffLocked = roundIsItemised && !hasStaffRemarks;
// Sections that share a group collapse onto one step, so the stepper stays
@@ -942,6 +970,7 @@ export function LicenseApplicationPage() {
ownerId={appId}
flagged={flaggedDocuments}
restrictToFlagged={roundIsItemised}
+ alsoUnlocked={unlockedDocuments}
readOnly={readOnly}
onUploaded={() => {
refetchAttachments();
diff --git a/apps/portal/src/app/i18n/locales/am.ts b/apps/portal/src/app/i18n/locales/am.ts
index 5f921d909..6d9d452a6 100644
--- a/apps/portal/src/app/i18n/locales/am.ts
+++ b/apps/portal/src/app/i18n/locales/am.ts
@@ -802,6 +802,14 @@ export const am: Translations = {
renewDays_one: 'አድስ — በ{{count}} ቀን ውስጥ ያበቃል',
renewDays_other: 'አድስ — በ{{count}} ቀናት ውስጥ ያበቃል',
renewFailed: 'ዕድሳት መጀመር አልተቻለም',
+ status: {
+ ACTIVE: 'የፀና',
+ EXPIRED: 'ጊዜው ያለፈበት',
+ SUSPENDED: 'የታገደ',
+ CANCELLED: 'የተሰረዘ',
+ SUPERSEDED: 'በአዲስ የምስክር ወረቀት የተተካ',
+ },
+ statusReason: 'ምክንያት፦ {{reason}}',
},
catalogue: {
emptyTitle: 'የሚሰሩበትን የስራ ዘርፍ ይንገሩን',
@@ -823,6 +831,8 @@ export const am: Translations = {
evaluationOnly: 'ግምገማ ብቻ',
startApplication: 'ማመልከቻ ጀምር',
addToOperations: 'ወደ ስራ ዘርፎቼ ጨምር',
+ lockedHint:
+ 'ከተመዘገቡ የስራ ዘርፎችዎ ውስጥ ስላልሆነ እስካሁን ማመልከት አይችሉም።',
},
},
@@ -1278,4 +1288,49 @@ export const am: Translations = {
},
},
},
+
+ documents: {
+ title: 'ሰነዶቼ',
+ subtitle: 'EMA ያወጣልዎት እያንዳንዱ ሰነድ፣ እንዲሁም ከመዝገቦችዎ ጋር የተያያዙ ፋይሎች።',
+ tabs: {
+ license: 'ፈቃዶች',
+ medical: 'ሕክምና',
+ seaService: 'የባህር አገልግሎት',
+ personal: 'የግል መረጃ',
+ },
+ issuedTitle: 'በ EMA የተሰጡ ሰነዶች',
+ licensesTitle: 'የምስክር ወረቀቶች እና ፈቃዶች',
+ kind: {
+ SEAMAN_BOOK: 'የመርከበኛ መጽሐፍ',
+ BTC_BASIC_TRAINING: 'የመሠረታዊ ስልጠና የምስክር ወረቀት (BTC)',
+ },
+ documentStatus: {
+ AWAITING_REGISTRATION: 'ምዝገባ በመጠበቅ ላይ',
+ PAYMENT_PENDING: 'ክፍያ በመጠበቅ ላይ',
+ PAID: 'ተከፍሏል',
+ PAYMENT_CONFIRMED: 'ሰነድ በመዘጋጀት ላይ',
+ SCHEDULED: 'የመውሰጃ ቀጠሮ ተይዟል',
+ ISSUED: 'ተሰጥቷል',
+ REJECTED: 'ተቀባይነት አላገኘም',
+ CANCELLED: 'ተሰርዟል',
+ },
+ view: 'ይመልከቱ',
+ notIssued: 'እስካሁን አልተሰጠም',
+ openFailed: 'ሰነዱን መክፈት አልተቻለም',
+ files: {
+ none: 'ምንም የተያያዘ ፋይል የለም።',
+ },
+ empty: {
+ licenses: 'እስካሁን የተሰጠዎት የምስክር ወረቀት ወይም ፈቃድ የለም።',
+ medical: 'እስካሁን በመዝገብ ላይ የሕክምና የምስክር ወረቀት የለም።',
+ seaService: 'እስካሁን በመዝገብ ላይ የባህር አገልግሎት መዝገብ የለም።',
+ },
+ personal: {
+ description: 'ከመርከበኛ ምዝገባዎ ጋር ያስገቡት ሰነዶች።',
+ noRegistration: 'እስካሁን የመርከበኛ ምዝገባ ስለሌለዎት በመዝገብ ላይ የግል ሰነዶች የሉም።',
+ startRegistration: 'ወደ መርከበኛ ምዝገባ ይሂዱ',
+ uploaded: 'ተሰቅሏል',
+ missing: 'አልተሰቀለም',
+ },
+ },
};
diff --git a/apps/portal/src/app/i18n/locales/en.ts b/apps/portal/src/app/i18n/locales/en.ts
index f3e0d4d83..209a5e8b6 100644
--- a/apps/portal/src/app/i18n/locales/en.ts
+++ b/apps/portal/src/app/i18n/locales/en.ts
@@ -802,6 +802,14 @@ export const en = {
renewDays_one: 'Renew — expires in {{count}} day',
renewDays_other: 'Renew — expires in {{count}} days',
renewFailed: 'Could not start the renewal',
+ status: {
+ ACTIVE: 'Active',
+ EXPIRED: 'Expired',
+ SUSPENDED: 'Suspended',
+ CANCELLED: 'Cancelled',
+ SUPERSEDED: 'Replaced by a newer certificate',
+ },
+ statusReason: 'Reason: {{reason}}',
},
catalogue: {
emptyTitle: 'Tell us what you operate as',
@@ -823,6 +831,8 @@ export const en = {
evaluationOnly: 'Evaluation only',
startApplication: 'Start application',
addToOperations: 'Add to my operations',
+ lockedHint:
+ 'Not one of your declared operations, so it cannot be applied for yet.',
},
},
@@ -1280,6 +1290,53 @@ export const en = {
},
},
},
+
+ documents: {
+ title: 'My documents',
+ subtitle:
+ 'Every document EMA has issued you, and the files attached to your records.',
+ tabs: {
+ license: 'Licences',
+ medical: 'Medical',
+ seaService: 'Sea Service',
+ personal: 'Personal Data',
+ },
+ issuedTitle: 'EMA-issued documents',
+ licensesTitle: 'Certificates and licences',
+ kind: {
+ SEAMAN_BOOK: 'Seaman Book',
+ BTC_BASIC_TRAINING: 'Basic Training Certificate (BTC)',
+ },
+ documentStatus: {
+ AWAITING_REGISTRATION: 'Awaiting registration',
+ PAYMENT_PENDING: 'Payment pending',
+ PAID: 'Paid',
+ PAYMENT_CONFIRMED: 'Preparing document',
+ SCHEDULED: 'Pickup scheduled',
+ ISSUED: 'Issued',
+ REJECTED: 'Rejected',
+ CANCELLED: 'Cancelled',
+ },
+ view: 'View',
+ notIssued: 'Not issued yet',
+ openFailed: 'Could not open the document',
+ files: {
+ none: 'No files attached.',
+ },
+ empty: {
+ licenses: 'No certificates or licences have been issued to you yet.',
+ medical: 'No medical certificates on file yet.',
+ seaService: 'No sea-service records on file yet.',
+ },
+ personal: {
+ description: 'The documents you submitted with your seafarer registration.',
+ noRegistration:
+ 'You have no seafarer registration yet, so there are no personal documents on file.',
+ startRegistration: 'Go to seafarer registration',
+ uploaded: 'Uploaded',
+ missing: 'Not uploaded',
+ },
+ },
};
export type Translations = typeof en;
diff --git a/libs/api/src/lib/features/licensing/licensing-api.ts b/libs/api/src/lib/features/licensing/licensing-api.ts
index d6c518fd2..ae2ffba53 100644
--- a/libs/api/src/lib/features/licensing/licensing-api.ts
+++ b/libs/api/src/lib/features/licensing/licensing-api.ts
@@ -1015,6 +1015,30 @@ export const licensingApi = baseApi
error ? [] : [itemTag('LicenseApplication', applicationId), listTag('Inspection')],
}),
+ /** Moves a booked visit: another day, slot, inspector or place. */
+ rescheduleInspection: builder.mutation<
+ Inspection,
+ {
+ inspectionId: string;
+ applicationId: string;
+ scheduledDate: string;
+ timeSlot: 'MORNING' | 'AFTERNOON';
+ inspectorId?: string;
+ location?: string;
+ reason?: string;
+ }
+ >({
+ query: ({ inspectionId, scheduledDate, timeSlot, inspectorId, location, reason }) => ({
+ url: `/inspections/${inspectionId}/schedule`,
+ method: 'PATCH',
+ // applicationId is for cache invalidation only; the visit knows its
+ // own application.
+ body: { scheduledDate, timeSlot, inspectorId, location, reason },
+ }),
+ invalidatesTags: (_r, error, { applicationId }) =>
+ error ? [] : [itemTag('LicenseApplication', applicationId), listTag('Inspection')],
+ }),
+
getInspections: builder.query({
query: (applicationId) => ({ url: `/inspections/application/${applicationId}` }),
providesTags: () => [listTag('Inspection')],
@@ -1152,6 +1176,7 @@ export const {
useScheduleIssuanceMutation,
useIssueCertificateMutation,
useScheduleInspectionMutation,
+ useRescheduleInspectionMutation,
useGetInspectionsQuery,
useRecordInspectionResultMutation,
useGetNotificationsQuery,
diff --git a/libs/api/src/lib/features/licensing/licensing.helpers.ts b/libs/api/src/lib/features/licensing/licensing.helpers.ts
index 390ee330b..d349fa4d2 100644
--- a/libs/api/src/lib/features/licensing/licensing.helpers.ts
+++ b/libs/api/src/lib/features/licensing/licensing.helpers.ts
@@ -598,6 +598,49 @@ interface ConditionLike {
anyOf?: ConditionLike[];
}
+/**
+ * Section keys a condition reads, recursing `anyOf`.
+ *
+ * `FieldCondition.field` is a `sectionKey.fieldKey` path, so the prefix names
+ * the section whose answer decides the condition.
+ */
+export function conditionSections(
+ condition: FieldCondition | undefined | null,
+): string[] {
+ if (!condition) return [];
+ if (condition.anyOf) return condition.anyOf.flatMap(conditionSections);
+ if (!condition.field) return [];
+ const [sectionKey] = condition.field.split('.');
+ return sectionKey ? [sectionKey] : [];
+}
+
+/**
+ * Sections whose visibility hangs on an answer in one of `flagged`.
+ *
+ * Mirrors the server's `sectionsDependingOn`: an officer flagging the section
+ * that holds the vessel category is asking for an answer that decides which
+ * fields in other sections are required, so those sections have to open too —
+ * otherwise the applicant sees newly-required fields they cannot edit and
+ * cannot resubmit.
+ */
+export function sectionsDependingOn(
+ sections: FormSectionConfig[],
+ flagged: Set,
+): Set {
+ const dependent = new Set();
+ if (flagged.size === 0) return dependent;
+
+ for (const section of sections) {
+ if (flagged.has(section.key)) continue;
+ const reads = [
+ section.showWhen,
+ ...(section.fields ?? []).map((field) => field.showWhen),
+ ].flatMap(conditionSections);
+ if (reads.some((key) => flagged.has(key))) dependent.add(section.key);
+ }
+ return dependent;
+}
+
export function conditionHolds(
condition: FieldCondition | undefined | null,
formData: Record>,