diff --git a/apps/backoffice/src/app/features/certificate-designer/components/DesignerToolbar.tsx b/apps/backoffice/src/app/features/certificate-designer/components/DesignerToolbar.tsx
index 4ab644bc6..c34a1e368 100644
--- a/apps/backoffice/src/app/features/certificate-designer/components/DesignerToolbar.tsx
+++ b/apps/backoffice/src/app/features/certificate-designer/components/DesignerToolbar.tsx
@@ -84,9 +84,11 @@ export function DesignerToolbar({
{validityDays != null
? t('designer.validityDays', '{{count}} days', { count: validityDays })
- : t('designer.validityMonths', '{{count}} months', {
- count: validityMonths,
- })}
+ : validityMonths
+ ? t('designer.validityMonths', '{{count}} months', {
+ count: validityMonths,
+ })
+ : t('designer.validityNone', 'Does not expire')}
{t('designer.validityEditedOn', '— set on Certificate Requirements → Behaviour')}
diff --git a/apps/backoffice/src/app/features/certificate-requirements/components/BehaviorTab.tsx b/apps/backoffice/src/app/features/certificate-requirements/components/BehaviorTab.tsx
index cab0bf4c4..d1606b697 100644
--- a/apps/backoffice/src/app/features/certificate-requirements/components/BehaviorTab.tsx
+++ b/apps/backoffice/src/app/features/certificate-requirements/components/BehaviorTab.tsx
@@ -72,9 +72,13 @@ function toDraft(licenseType: LicenseType): Draft {
licenseType.capitalThreshold == null
? null
: Number(licenseType.capitalThreshold),
+ // Zero is a real stored state for `validityMonths` (a type that issues
+ // nothing with an expiry) and is kept. Zero in the other two is not a
+ // policy anyone set — it is an unfilled column — and seeding a box with a
+ // value below its own floor only produces a save the server rejects.
validityMonths: licenseType.validityMonths ?? 12,
- validityDays: licenseType.validityDays ?? null,
- renewalWindowDays: licenseType.renewalWindowDays ?? 60,
+ validityDays: licenseType.validityDays || null,
+ renewalWindowDays: licenseType.renewalWindowDays || 60,
expiryReminderDays: licenseType.expiryReminderDays ?? [60, 30, 7],
requiresOperatorMode: licenseType.requiresOperatorMode ?? true,
allowMultipleOpenDrafts: licenseType.allowMultipleOpenDrafts ?? false,
@@ -114,14 +118,52 @@ export function BehaviorTab({ licenseType }: { licenseType: LicenseType }) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [licenseType.id]);
- function set(key: K, value: Draft[K]) {
- setDraft((current) => ({ ...current, [key]: value }));
+ function patch(values: Partial) {
+ setDraft((current) => ({ ...current, ...values }));
setDirty(true);
}
+ function set(key: K, value: Draft[K]) {
+ patch({ [key]: value } as Partial);
+ }
+
+ // Nothing this type issues carries an expiry date — a transfer, or any
+ // one-off record. Renewal, its window and its reminders are all measured
+ // against an expiry that never arrives, so none of them are asked for.
+ const expires = draft.validityDays !== null || draft.validityMonths > 0;
+
+ // The server's floor for a stated term is 6 months, so no-expiry is a state
+ // this form can hold and edit around but cannot switch a type into. Offered
+ // only where it is already what the type is, rather than as an option whose
+ // save would be refused.
+ const noExpiryAvailable =
+ (licenseType.validityMonths ?? 12) === 0 && !licenseType.validityDays;
+
async function onSave() {
+ // Only the settings this form actually asked for. The endpoint patches, so
+ // an omitted field keeps its stored value — and the fields hidden above are
+ // hidden precisely because the type has no such policy, which the server
+ // stores as a zero its own validators then refuse (`validityMonths` has a
+ // floor of 6, `renewalWindowDays` of 1). Echoing those back is what made
+ // saving an ownership transfer fail outright.
+ const {
+ validityMonths,
+ validityDays,
+ renewalWindowDays,
+ expiryReminderDays,
+ ...rest
+ } = draft;
+
const ok = await run(
- () => save({ id: licenseType.id, ...draft }).unwrap(),
+ () =>
+ save({
+ id: licenseType.id,
+ ...rest,
+ ...(expires ? { validityMonths, validityDays } : {}),
+ ...(expires && draft.renewalEnabled
+ ? { renewalWindowDays, expiryReminderDays }
+ : {}),
+ }).unwrap(),
t('certReq.behavior.saved', 'Configuration saved.'),
);
if (ok) setDirty(false);
@@ -289,94 +331,142 @@ export function BehaviorTab({ licenseType }: { licenseType: LicenseType }) {
the seed says `validityMonths: 12` and this now says "12 Months",
so the two read the same. Months advance the calendar (issued on
the 31st, expires on the 31st); days are for terms shorter than a
- month can express. */}
+ month can express. The third unit is no term at all, which the
+ server stores as `validityMonths: 0`. */}
- {
- const next = typeof v === 'number' ? v : 0;
- if (!next) return;
- if (draft.validityDays !== null) set('validityDays', next);
- else set('validityMonths', next);
- }}
- // Matches the server's ranges, so the box cannot offer a value the
- // save would reject: 1–3650 days, or 6–240 months.
- min={draft.validityDays !== null ? 1 : 6}
- max={draft.validityDays !== null ? 3650 : 240}
- allowNegative={false}
- disabled={!canEdit}
- flex={1}
- />
+ {expires && (
+ {
+ const next = typeof v === 'number' ? v : 0;
+ if (!next) return;
+ if (draft.validityDays !== null) set('validityDays', next);
+ else set('validityMonths', next);
+ }}
+ // Matches the server's ranges, so the box cannot offer a value the
+ // save would reject: 1–3650 days, or 6–240 months.
+ min={draft.validityDays !== null ? 1 : 6}
+ max={draft.validityDays !== null ? 3650 : 240}
+ allowNegative={false}
+ disabled={!canEdit}
+ flex={1}
+ />
+ )}
- set('renewalEnabled', e.currentTarget.checked)}
- label={t('certReq.behavior.renewalEnabled', 'Holders may renew this licence')}
- description={t(
- 'certReq.behavior.renewalEnabledHint',
- 'Off for one-off issues — a registration that does not lapse, or a waiver written for a single shipment.',
- )}
- disabled={!canEdit}
- />
+ {!expires && (
+
+ {t(
+ 'certReq.behavior.noExpiryHint',
+ 'What this type issues never expires — an ownership transfer, or any one-off record. There is no renewal policy to set.',
+ )}
+
+ )}
- {/* The window and the reminders are both measured against an expiry a
- non-renewing licence never reaches, so they are hidden rather than
- shown as settings that quietly do nothing. */}
- {draft.renewalEnabled && (
+ {expires && (
<>
-
- set('renewalWindowDays', typeof v === 'number' ? v : draft.renewalWindowDays)
- }
- min={1}
- max={365}
- allowNegative={false}
+ set('renewalEnabled', e.currentTarget.checked)}
+ label={t('certReq.behavior.renewalEnabled', 'Holders may renew this licence')}
+ description={t(
+ 'certReq.behavior.renewalEnabledHint',
+ 'Off for one-off issues — a registration that does not lapse, or a waiver written for a single shipment.',
+ )}
disabled={!canEdit}
/>
-
- set(
- 'expiryReminderDays',
- values.map(Number).sort((a, b) => b - a),
- )
- }
- disabled={!canEdit}
- clearable
- />
+ {/* The window and the reminders are both measured against an expiry
+ a non-renewing licence never reaches, so they are hidden rather
+ than shown as settings that quietly do nothing. */}
+ {draft.renewalEnabled && (
+ <>
+
+ set('renewalWindowDays', typeof v === 'number' ? v : draft.renewalWindowDays)
+ }
+ min={1}
+ max={365}
+ allowNegative={false}
+ disabled={!canEdit}
+ />
+
+
+ set(
+ 'expiryReminderDays',
+ values.map(Number).sort((a, b) => b - a),
+ )
+ }
+ disabled={!canEdit}
+ clearable
+ />
+ >
+ )}
>
)}
diff --git a/apps/backoffice/src/app/i18n/locales/am.ts b/apps/backoffice/src/app/i18n/locales/am.ts
index ee4ee2eef..e5a4146f6 100644
--- a/apps/backoffice/src/app/i18n/locales/am.ts
+++ b/apps/backoffice/src/app/i18n/locales/am.ts
@@ -88,8 +88,6 @@ export const am: Translations = {
btcQueue: "የBTC ወረፋ",
cocQueue: "የCoC ወረፋ",
copQueue: "የCoP ወረፋ",
- endorsementCocQueue: "የCoC እውቅና ወረፋ",
- endorsementGocQueue: "የGOC እውቅና ወረፋ",
endorsementQueue: "የማስተያየት ወረፋ",
vesselRegistrations: "የመርከብ ምዝገባ",
// vesselRegistrationReport: 'የምዝገባ ሪፖርት',
@@ -1274,6 +1272,7 @@ export const am: Translations = {
validityMonths_other: "{{count}} ወራት",
validityDays_one: "{{count}} ቀን",
validityDays_other: "{{count}} ቀናት",
+ validityNone: "ጊዜው አያልፍም",
validityEditedOn: "— በምስክር ወረቀት መስፈርቶች → ባህሪ ውስጥ ይዘጋጃል",
newVersion: "አዲስ ስሪት",
versions: "ስሪቶች",
@@ -1360,8 +1359,11 @@ export const am: Translations = {
validityUnit: "የሚቆይበት መለኪያ",
unitMonths: "ወራት",
unitDays: "ቀናት",
+ unitNone: "ጊዜው አያልፍም",
validityHint:
"ፈቃድ ሲሰጥ ተግባራዊ ይሆናል። ቀደም ብለው የተሰጡ ፈቃዶች የተሰጣቸውን የማብቂያ ቀን ይይዛሉ።",
+ noExpiryHint:
+ "ይህ ዓይነት የሚሰጠው ሰነድ ጊዜው አያልፍም — የባለቤትነት ዝውውር ወይም አንድ ጊዜ ብቻ የሚሰጥ መዝገብ። የሚቀመጥ የዕድሳት መመሪያ የለም።",
requiresSeafarer: "የጸና የመርከበኛ ምዝገባ ያስፈልገዋል",
requiresSeafarerHint:
"ይህንን ዓይነት እንደ የመርከበኛ የምስክር ወረቀት ያመለክታል፤ በመርከበኛው ፖርታል ላይ እንዲታይ የሚያደርገው ይኸው ነው።",
diff --git a/apps/backoffice/src/app/i18n/locales/en.ts b/apps/backoffice/src/app/i18n/locales/en.ts
index 342ace75b..f4e2798e5 100644
--- a/apps/backoffice/src/app/i18n/locales/en.ts
+++ b/apps/backoffice/src/app/i18n/locales/en.ts
@@ -88,8 +88,6 @@ export const en = {
postWaiverQueue: 'Post-Waiver Queue',
cocQueue: 'CoC Queue',
copQueue: 'CoP Queue',
- endorsementCocQueue: 'CoC Endorsement Queue',
- endorsementGocQueue: 'GOC Endorsement Queue',
endorsementQueue: 'Endorsement Queue',
vesselRegistrations: 'Vessel Registration',
vesselTransfers: 'Vessel Ownership Transfer',
@@ -1281,6 +1279,7 @@ export const en = {
validityMonths_other: '{{count}} months',
validityDays_one: '{{count}} day',
validityDays_other: '{{count}} days',
+ validityNone: 'Does not expire',
validityEditedOn: '— set on Certificate Requirements → Behaviour',
newVersion: 'New version',
versions: 'Versions',
@@ -1366,8 +1365,11 @@ export const en = {
validityUnit: 'Validity unit',
unitMonths: 'Months',
unitDays: 'Days',
+ unitNone: 'Does not expire',
validityHint:
'Applied when a licence is issued. Licences already issued keep the expiry date they were given.',
+ noExpiryHint:
+ 'What this type issues never expires — an ownership transfer, or any one-off record. There is no renewal policy to set.',
requiresSeafarer: 'Requires an active seafarer registration',
requiresSeafarerHint:
'Also marks this type as a seafarer certificate, which is what makes it appear in the seafarer portal.',
diff --git a/apps/backoffice/src/app/layouts/nav-config.ts b/apps/backoffice/src/app/layouts/nav-config.ts
index 7df1c5bcf..9ab587db6 100644
--- a/apps/backoffice/src/app/layouts/nav-config.ts
+++ b/apps/backoffice/src/app/layouts/nav-config.ts
@@ -189,18 +189,6 @@ export const NAV_SECTIONS: NavSection[] = [
icon: IconShieldCheck,
permissions: SEAFARER_QUEUE,
},
- {
- to: "/licence-review/type/ENDORSEMENT_COC",
- label: "nav.endorsementCocQueue",
- icon: IconRubberStamp,
- permissions: [P.VIEW_SEAFARER_REGISTRY],
- },
- {
- to: "/licence-review/type/ENDORSEMENT_GOC",
- label: "nav.endorsementGocQueue",
- icon: IconRubberStamp,
- permissions: [P.VIEW_SEAFARER_REGISTRY],
- },
{
to: "/licence-review/type/ENDORSEMENT_SEAFARER",
label: "nav.endorsementQueue",
diff --git a/apps/backoffice/vite.config.mts b/apps/backoffice/vite.config.mts
index 355a26fdf..6a1285609 100644
--- a/apps/backoffice/vite.config.mts
+++ b/apps/backoffice/vite.config.mts
@@ -12,16 +12,13 @@ export default defineConfig({
server: {
port: 4201,
host: 'localhost',
+ proxy: {
+ '/api': {
+ target: 'https://ema-api-dev.triaplc.com',
+ changeOrigin: true,
+ },
+ },
},
-// server: {
-// port: 4201,
-// proxy: {
-// '/api': {
-// target: 'http://localhost:3000', // change from 'https://ema-api-dev.triaplc.com'
-// changeOrigin: true,
-// },
-// },
-// },
preview: { port: 4201, host: 'localhost' },
plugins: [react(), nxViteTsPaths()],
resolve: {
diff --git a/apps/portal/src/app/features/licensing/components/LicenseCatalogue.tsx b/apps/portal/src/app/features/licensing/components/LicenseCatalogue.tsx
index c948e5e58..189eb8f00 100644
--- a/apps/portal/src/app/features/licensing/components/LicenseCatalogue.tsx
+++ b/apps/portal/src/app/features/licensing/components/LicenseCatalogue.tsx
@@ -322,8 +322,16 @@ function LicenseTypeCard({
)}
{type.issuesCertificate ? (
+ // A term in days wins over the months column, and a type with
+ // neither issues something that simply does not expire — a
+ // transfer, or any one-off record. Reading `validityMonths`
+ // alone badged those "0 months".
- {t('licensing.catalogue.validityBadge', { months: type.validityMonths })}
+ {type.validityDays
+ ? t('licensing.catalogue.validityDaysBadge', { count: type.validityDays })
+ : type.validityMonths
+ ? t('licensing.catalogue.validityBadge', { months: type.validityMonths })
+ : t('licensing.catalogue.noExpiryBadge')}
) : (
diff --git a/apps/portal/src/app/features/seafarer-registration/components/fields.tsx b/apps/portal/src/app/features/seafarer-registration/components/fields.tsx
index 5676ec900..95a205327 100644
--- a/apps/portal/src/app/features/seafarer-registration/components/fields.tsx
+++ b/apps/portal/src/app/features/seafarer-registration/components/fields.tsx
@@ -61,7 +61,7 @@ export function SelectField(p: FieldProps & { options: { value: string; label: s
);
}
-export function DateField(p: FieldProps) {
+export function DateField(p: FieldProps & { minDate?: Date | string; maxDate?: Date | string }) {
return (