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 5c19085fa..3a8296005 100644 --- a/apps/backoffice/src/app/features/certificate-designer/components/DesignerToolbar.tsx +++ b/apps/backoffice/src/app/features/certificate-designer/components/DesignerToolbar.tsx @@ -1,4 +1,4 @@ -import { Button, Group, NumberInput, Select, Tooltip } from '@mantine/core'; +import { Button, Group, Select, Stack, Text } from '@mantine/core'; import { IconPlus } from '@tabler/icons-react'; import { useTranslation } from 'react-i18next'; import { useLocalized, type LicenseType, type Rank } from '@ema-platform/api'; @@ -12,12 +12,9 @@ interface Props { ranks: Rank[]; rankId: string | null; onRankChange: (id: string | null) => void; + /** Shown for context only; edited on Certificate Requirements → Behaviour. */ validityMonths: number; - onValidityChange: (months: number) => void; - currentValidityMonths?: number | null; canEdit: boolean; - savingValidity: boolean; - onSaveValidity: () => void; onNewVersion: () => void; } @@ -30,11 +27,7 @@ export function DesignerToolbar({ rankId, onRankChange, validityMonths, - onValidityChange, - currentValidityMonths, canEdit, - savingValidity, - onSaveValidity, onNewVersion, }: Props) { const { t } = useTranslation(); @@ -73,38 +66,27 @@ export function DesignerToolbar({ /> )} - {/* Validity lives beside the design because it is the other half of - what a certificate promises. */} - onValidityChange(Math.round(Number(value || 0) * 12))} - min={0.5} - max={20} - step={0.5} - decimalScale={1} - w={190} - disabled={!canEdit} - /> - - - - - + {/* Read-only here. Validity is one policy decision with the renewal + window and the expiry reminders, so it is edited in one place — + Certificate Requirements → Behaviour — rather than from two screens + behind two different permissions. Still shown, because a designer + laying out a certificate that prints an expiry needs to see the term + it promises. */} + {typeId && ( + + + {t('designer.validityYears', 'Valid for (years)')} + + + + {Number((validityMonths / 12).toFixed(2))} + + + {t('designer.validityEditedOn', '— set on Certificate Requirements → Behaviour')} + + + + )}
diff --git a/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx b/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx index f9401f3e4..8f5597f49 100644 --- a/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx +++ b/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx @@ -29,7 +29,6 @@ import { useGetRanksQuery, useGetTemplateVariablesQuery, usePublishLicenseTemplateMutation, - useUpdateLicenseValidityMutation, useUpdateLicenseTemplateMutation, } from '@ema-platform/api'; import { EmptyState, ErrorState, PageHeader, PdfPreviewModal } from '@ema-platform/ui'; @@ -88,7 +87,6 @@ export function CertificateDesignerPage() { const [publishTemplate, { isLoading: publishing }] = usePublishLicenseTemplateMutation(); const [archiveTemplate] = useArchiveLicenseTemplateMutation(); const [deleteTemplate] = useDeleteLicenseTemplateMutation(); - const [updateValidity, { isLoading: savingValidity }] = useUpdateLicenseValidityMutation(); const draft = useTemplateDraft(templates); const run = useDesignerActions(); @@ -96,7 +94,6 @@ export function CertificateDesignerPage() { const [newOpen, setNewOpen] = useState(false); const [newName, setNewName] = useState(''); - const [validityMonths, setValidityMonths] = useState(12); const [mode, setMode] = useState<'canvas' | 'source'>('canvas'); const selectedType = licenseTypes?.items?.find((type) => type.id === typeId); @@ -127,10 +124,6 @@ export function CertificateDesignerPage() { if (!typeId && licenseTypes?.items?.length) setTypeId(licenseTypes.items[0].id); }, [licenseTypes, typeId]); - useEffect(() => { - if (selectedType) setValidityMonths(selectedType.validityMonths ?? 12); - }, [selectedType]); - // Switching licence type leaves a stale rank selected from the previous // type's ladder — reset to the type's default design. useEffect(() => { @@ -169,17 +162,8 @@ export function CertificateDesignerPage() { setRankId(value); draft.setSelectedId(null); }} - validityMonths={validityMonths} - onValidityChange={setValidityMonths} - currentValidityMonths={selectedType?.validityMonths} + validityMonths={selectedType?.validityMonths ?? 12} canEdit={canEdit} - savingValidity={savingValidity} - onSaveValidity={() => - run( - () => updateValidity({ id: typeId as string, validityMonths }).unwrap(), - t('designer.validitySaved', 'Validity updated'), - ) - } onNewVersion={startNewVersion} /> 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 28f348ab1..6acfd90b4 100644 --- a/apps/backoffice/src/app/features/certificate-requirements/components/BehaviorTab.tsx +++ b/apps/backoffice/src/app/features/certificate-requirements/components/BehaviorTab.tsx @@ -36,6 +36,8 @@ interface Draft { requiresSeafarerRegistration: boolean; requiresValidMedical: boolean; minSeaTimeDays: number | null; + capitalThreshold: number | null; + validityMonths: number; renewalWindowDays: number; expiryReminderDays: number[]; requiresOperatorMode: boolean; @@ -59,6 +61,11 @@ function toDraft(licenseType: LicenseType): Draft { licenseType.requiresSeafarerRegistration ?? false, requiresValidMedical: licenseType.requiresValidMedical ?? false, minSeaTimeDays: licenseType.minSeaTimeDays ?? null, + capitalThreshold: + licenseType.capitalThreshold == null + ? null + : Number(licenseType.capitalThreshold), + validityMonths: licenseType.validityMonths ?? 12, renewalWindowDays: licenseType.renewalWindowDays ?? 60, expiryReminderDays: licenseType.expiryReminderDays ?? [60, 30, 7], requiresOperatorMode: licenseType.requiresOperatorMode ?? true, @@ -194,10 +201,25 @@ export function BehaviorTab({ licenseType }: { licenseType: LicenseType }) { {t( 'certReq.behavior.eligibilityHint', - 'Checked when an applicant submits. Tightening a gate can stop someone mid-way from submitting a draft they have already started.', + 'Checked when an applicant submits, and the capital requirement again when an officer approves. Tightening a gate can stop someone mid-way from submitting a draft they have already started.', )} + set('capitalThreshold', v)} + disabled={!canEdit} + min={0} + defaultValue={1_000_000} + thousandSeparator + /> + set('requiresSeafarerRegistration', e.currentTarget.checked)} @@ -243,7 +265,27 @@ export function BehaviorTab({ licenseType }: { licenseType: LicenseType }) { /> -
+
+ {/* Stored in months, edited in years: a licence term is a number of + years to everyone who works with one. Half-years stay expressible. */} + + set('validityMonths', Math.round(Number(v || 0) * 12) || draft.validityMonths) + } + min={0.5} + max={20} + step={0.5} + decimalScale={1} + allowNegative={false} + disabled={!canEdit} + /> + void; disabled: boolean; min: number; + /** Seeded when the switch is turned on. Defaults to the minimum. */ + defaultValue?: number; + thousandSeparator?: boolean; }) { return ( onChange(e.currentTarget.checked ? min || 1 : null)} + onChange={(e) => + onChange(e.currentTarget.checked ? (defaultValue ?? min ?? 1) : null) + } label={switchLabel} description={description} disabled={disabled} @@ -414,6 +463,8 @@ function NullableNumber({ onChange={(v) => onChange(typeof v === 'number' ? v : value)} min={min} allowNegative={false} + thousandSeparator={thousandSeparator ? ',' : undefined} + decimalScale={thousandSeparator ? 2 : undefined} disabled={disabled} /> )} diff --git a/apps/backoffice/src/app/i18n/locales/am.ts b/apps/backoffice/src/app/i18n/locales/am.ts index 3c8eb5a9f..d83b5d451 100644 --- a/apps/backoffice/src/app/i18n/locales/am.ts +++ b/apps/backoffice/src/app/i18n/locales/am.ts @@ -1223,12 +1223,10 @@ export const am: Translations = { designer: { title: "የምስክር ወረቀት ንድፍ", - subtitle: "ለፈቃድ ባለቤቶች የሚሰጠውን የምስክር ወረቀት ይንደፉ፣ የሚቆይበትንም ጊዜ ያዘጋጁ።", + subtitle: "ለፈቃድ ባለቤቶች የሚሰጠውን የምስክር ወረቀት ይንደፉ።", licenceType: "የፈቃድ ዓይነት", validityYears: "የሚቆይበት (ዓመታት)", - validityHint: "ፈቃድ ሲሰጥ ተግባራዊ ይሆናል", - saveValidity: "የሚቆይበትን ጊዜ አስቀምጥ", - validitySaved: "የሚቆይበት ጊዜ ተዘምኗል", + validityEditedOn: "— በምስክር ወረቀት መስፈርቶች → ባህሪ ውስጥ ይዘጋጃል", newVersion: "አዲስ ስሪት", versions: "ስሪቶች", name: "የስሪት ስም", @@ -1296,7 +1294,14 @@ export const am: Translations = { registrationKind: "ምዝገባ", eligibility: "የብቁነት መስፈርቶች", eligibilityHint: - "አመልካቹ ሲያስገባ ይመረመራሉ። መስፈርትን ማጥበቅ ቀደም ብሎ ረቂቅ የጀመረን ሰው ከማስገባት ሊያግደው ይችላል።", + "አመልካቹ ሲያስገባ ይመረመራሉ፤ የካፒታል መስፈርቱ ደግሞ ኃላፊው ሲያጸድቅ እንደገና ይመረመራል። መስፈርትን ማጥበቅ ቀደም ብሎ ረቂቅ የጀመረን ሰው ከማስገባት ሊያግደው ይችላል።", + capitalThreshold: "አነስተኛ የተከፈለ ካፒታል", + capitalOn: "አነስተኛ የተከፈለ ካፒታል ይጠየቅ", + capitalHint: + "ኃላፊው ከዚህ እኩል ወይም በላይ የሆነ ካፒታል እስኪያረጋግጥ ድረስ ማጽደቅ አይችልም። ለአዲሶቹ ብቻ ሳይሆን ቀደም ብለው በወረፋ ላይ ላሉ ማመልከቻዎችም ይሠራል።", + validityYears: "የሚቆይበት (ዓመታት)", + validityHint: + "ፈቃድ ሲሰጥ ተግባራዊ ይሆናል። ቀደም ብለው የተሰጡ ፈቃዶች የተሰጣቸውን የማብቂያ ቀን ይይዛሉ።", requiresSeafarer: "የጸና የመርከበኛ ምዝገባ ያስፈልገዋል", requiresSeafarerHint: "ይህንን ዓይነት እንደ የመርከበኛ የምስክር ወረቀት ያመለክታል፤ በመርከበኛው ፖርታል ላይ እንዲታይ የሚያደርገው ይኸው ነው።", @@ -1307,7 +1312,7 @@ export const am: Translations = { certificateCategoryHint: "ይህ የሚሰጠው የሰነድ ዓይነት። ማረጋገጫዎች በመርከበኛው ፖርታል ላይ ለብቻቸው ይመደባሉ።", notACertificate: "የምስክር ወረቀት አይደለም", - renewal: "እድሳት", + renewal: "የሚቆይበት ጊዜና እድሳት", renewalWindow: "እድሳት የሚከፈትበት (ጊዜው ከማብቃቱ በፊት ያሉ ቀናት)", reminders: "የማብቂያ አስታዋሾች (ቀደም ብለው ያሉ ቀናት)", remindersHint: "ባለቤቱ በእያንዳንዱ በእነዚህ ጊዜያት ይታሰባል።", diff --git a/apps/backoffice/src/app/i18n/locales/en.ts b/apps/backoffice/src/app/i18n/locales/en.ts index 40e501bdc..8279081ec 100644 --- a/apps/backoffice/src/app/i18n/locales/en.ts +++ b/apps/backoffice/src/app/i18n/locales/en.ts @@ -1229,12 +1229,10 @@ export const en = { designer: { title: 'Certificate designer', - subtitle: 'Design the certificate issued to licence holders, and set how long it stays valid.', + subtitle: 'Design the certificate issued to licence holders.', licenceType: 'Licence type', validityYears: 'Valid for (years)', - validityHint: 'Applied when a licence is issued', - saveValidity: 'Save validity', - validitySaved: 'Validity updated', + validityEditedOn: '— set on Certificate Requirements → Behaviour', newVersion: 'New version', versions: 'Versions', name: 'Version name', @@ -1301,7 +1299,14 @@ export const en = { registrationKind: 'Registration', eligibility: 'Eligibility gates', eligibilityHint: - 'Checked when an applicant submits. Tightening a gate can stop someone mid-way from submitting a draft they have already started.', + 'Checked when an applicant submits, and the capital requirement again when an officer approves. Tightening a gate can stop someone mid-way from submitting a draft they have already started.', + capitalThreshold: 'Minimum paid-up capital', + capitalOn: 'Require a minimum paid-up capital', + capitalHint: + 'An officer cannot approve until they have verified capital at or above this. Applies to applications already in the queue, not just new ones.', + validityYears: 'Valid for (years)', + validityHint: + 'Applied when a licence is issued. Licences already issued keep the expiry date they were given.', 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.', @@ -1312,7 +1317,7 @@ export const en = { certificateCategoryHint: 'What kind of document this issues. Endorsements are grouped separately in the seafarer portal.', notACertificate: 'Not a certificate', - renewal: 'Renewal', + renewal: 'Validity and renewal', renewalWindow: 'Renewal opens (days before expiry)', reminders: 'Expiry reminders (days before)', remindersHint: 'The holder is reminded at each of these offsets.', diff --git a/libs/api/src/lib/features/licensing/licensing-api.ts b/libs/api/src/lib/features/licensing/licensing-api.ts index e4ffc9770..e1a56677a 100644 --- a/libs/api/src/lib/features/licensing/licensing-api.ts +++ b/libs/api/src/lib/features/licensing/licensing-api.ts @@ -226,6 +226,8 @@ export const licensingApi = baseApi requiresSeafarerRegistration?: boolean; requiresValidMedical?: boolean; minSeaTimeDays?: number | null; + validityMonths?: number; + capitalThreshold?: number | null; renewalWindowDays?: number; expiryReminderDays?: number[]; requiresOperatorMode?: boolean;