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..4ab644bc6 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,11 @@ 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; + /** Non-null when the type is configured in days rather than months. */ + validityDays?: number | null; canEdit: boolean; - savingValidity: boolean; - onSaveValidity: () => void; onNewVersion: () => void; } @@ -30,11 +29,8 @@ export function DesignerToolbar({ rankId, onRankChange, validityMonths, - onValidityChange, - currentValidityMonths, + validityDays, canEdit, - savingValidity, - onSaveValidity, onNewVersion, }: Props) { const { t } = useTranslation(); @@ -73,38 +69,31 @@ 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.validity', 'Valid for')} + + + + {validityDays != null + ? t('designer.validityDays', '{{count}} days', { count: validityDays }) + : t('designer.validityMonths', '{{count}} months', { + count: validityMonths, + })} + + + {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..b46d68689 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,9 @@ export function CertificateDesignerPage() { setRankId(value); draft.setSelectedId(null); }} - validityMonths={validityMonths} - onValidityChange={setValidityMonths} - currentValidityMonths={selectedType?.validityMonths} + validityMonths={selectedType?.validityMonths ?? 12} + validityDays={selectedType?.validityDays ?? null} 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 new file mode 100644 index 000000000..cab0bf4c4 --- /dev/null +++ b/apps/backoffice/src/app/features/certificate-requirements/components/BehaviorTab.tsx @@ -0,0 +1,550 @@ +import { useEffect, useState } from 'react'; +import { + Alert, + Button, + Group, + MultiSelect, + NumberInput, + Paper, + Select, + Stack, + Switch, + Text, + TextInput, + Tooltip, +} from '@mantine/core'; +import { IconAlertTriangle, IconDeviceFloppy } from '@tabler/icons-react'; +import { useTranslation } from 'react-i18next'; +import { LICENSE_PERMISSIONS, usePermissions } from '@ema-platform/auth'; +import { + useUpdateLicenseBehaviorMutation, + type CertificateCategory, + type CompletionEffect, + type LicenseType, + type ServiceKind, + type WorkflowProfile, +} from '@ema-platform/api'; +import { useRequirementActions } from '../hooks/useRequirementActions'; + +/** The shape the form edits — every field the behaviour endpoint accepts. */ +interface Draft { + workflowProfile: WorkflowProfile; + serviceKind: ServiceKind; + completionEffect: CompletionEffect | null; + certificateCategory: CertificateCategory | null; + requiresExamination: boolean; + inspectionRequired: boolean; + issuesCertificate: boolean; + renewalEnabled: boolean; + requiresSeafarerRegistration: boolean; + requiresValidMedical: boolean; + minSeaTimeDays: number | null; + capitalThreshold: number | null; + validityMonths: number; + validityDays: number | null; + renewalWindowDays: number; + expiryReminderDays: number[]; + requiresOperatorMode: boolean; + allowMultipleOpenDrafts: boolean; + requiresIssuanceScheduling: boolean; + uniqueFormKeyPath: string | null; + slaHours: number | null; +} + +/** Offsets EMA reminds on. Fixed rather than free-form — these are policy, not arithmetic. */ +const REMINDER_OFFSETS = ['90', '60', '30', '14', '7']; + +function toDraft(licenseType: LicenseType): Draft { + return { + workflowProfile: licenseType.workflowProfile ?? 'STANDARD', + serviceKind: licenseType.serviceKind ?? 'LICENSE', + completionEffect: licenseType.completionEffect ?? null, + certificateCategory: licenseType.certificateCategory ?? null, + requiresExamination: licenseType.requiresExamination ?? false, + inspectionRequired: licenseType.inspectionRequired ?? true, + issuesCertificate: licenseType.issuesCertificate ?? true, + renewalEnabled: licenseType.renewalEnabled ?? true, + requiresSeafarerRegistration: + licenseType.requiresSeafarerRegistration ?? false, + requiresValidMedical: licenseType.requiresValidMedical ?? false, + minSeaTimeDays: licenseType.minSeaTimeDays ?? null, + capitalThreshold: + licenseType.capitalThreshold == null + ? null + : Number(licenseType.capitalThreshold), + validityMonths: licenseType.validityMonths ?? 12, + validityDays: licenseType.validityDays ?? null, + renewalWindowDays: licenseType.renewalWindowDays ?? 60, + expiryReminderDays: licenseType.expiryReminderDays ?? [60, 30, 7], + requiresOperatorMode: licenseType.requiresOperatorMode ?? true, + allowMultipleOpenDrafts: licenseType.allowMultipleOpenDrafts ?? false, + requiresIssuanceScheduling: licenseType.requiresIssuanceScheduling ?? false, + uniqueFormKeyPath: licenseType.uniqueFormKeyPath ?? null, + slaHours: licenseType.slaHours ?? null, + }; +} + +/** + * How one licence type behaves: the course it runs, who may apply, when it + * renews and what rules the applicant meets. + * + * These were seed-only until now — changing an SLA target or a renewal window + * meant editing a file and redeploying. They are read live off the licence + * type rather than snapshotted onto applications, so a change here applies to + * files already in the queue as well as new ones. For the three settings that + * decide an application's course the server refuses the change outright while + * anything is still awaiting a decision, rather than stranding it. + */ +export function BehaviorTab({ licenseType }: { licenseType: LicenseType }) { + const { t } = useTranslation(); + const run = useRequirementActions(); + const { can } = usePermissions(); + const canEdit = can([LICENSE_PERMISSIONS.UPDATE_LICENSE_TYPE]); + + const [save, { isLoading: saving }] = useUpdateLicenseBehaviorMutation(); + const [draft, setDraft] = useState(() => toDraft(licenseType)); + const [dirty, setDirty] = useState(false); + + // Keyed on the id alone, like FormSchemaTab: this tab's own save invalidates + // the licence-type list, and the refetch that follows must not overwrite an + // edit the administrator is still working on. + useEffect(() => { + setDraft(toDraft(licenseType)); + setDirty(false); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [licenseType.id]); + + function set(key: K, value: Draft[K]) { + setDraft((current) => ({ ...current, [key]: value })); + setDirty(true); + } + + async function onSave() { + const ok = await run( + () => save({ id: licenseType.id, ...draft }).unwrap(), + t('certReq.behavior.saved', 'Configuration saved.'), + ); + if (ok) setDirty(false); + } + + return ( + +
+ } + > + + {t( + 'certReq.behavior.workflowWarning', + 'These three settings decide the course an application runs. They cannot be changed while applications of this type are still awaiting a decision — saving will be refused until those are decided.', + )} + + + + set('completionEffect', (v as CompletionEffect) ?? null)} + placeholder={t('certReq.behavior.noEffect', 'No side effect')} + clearable + disabled={!canEdit} + /> + + set('requiresExamination', e.currentTarget.checked)} + label={t('certReq.behavior.requiresExamination', 'Requires an examination')} + description={t( + 'certReq.behavior.requiresExaminationHint', + 'Routes the application through eligibility, then exam, then certificate. Set the three stage fees on the payment configuration screen.', + )} + disabled={!canEdit} + /> + + set('issuesCertificate', e.currentTarget.checked)} + label={t('certReq.behavior.issuesCertificate', 'Issues a certificate')} + description={t( + 'certReq.behavior.issuesCertificateHint', + 'Turn off for a type that ends with an EMA decision and never reaches a payment stage. Turning it back on requires a new-application fee to be set, or approved applicants would be asked for a fee that does not exist.', + )} + disabled={!canEdit} + /> + + ({ + value: v, + label: v, + }))} + value={draft.certificateCategory} + onChange={(v) => set('certificateCategory', (v as CertificateCategory) ?? null)} + placeholder={t('certReq.behavior.notACertificate', 'Not a certificate')} + clearable + disabled={!canEdit} + /> +
+ +
+ {/* Amount + unit rather than a years box that silently divides by 12: + 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. */} + + { + 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} + /> + v && setDraft((d) => ({ ...d, applicationKind: v as ApplicationKind }))} - allowDeselect={false} - disabled={!isNew} - description={!isNew ? t('certReq.doc.kindLocked', 'Application kind cannot change once created') : undefined} - /> + {personal && ( + + + {t('certReq.doc.scope', 'Applies to')} + + setAppliesToAll(v === 'all')} + data={[ + { value: 'all', label: t('certReq.doc.scopeAll', 'All licences') }, + { + value: 'selected', + label: t('certReq.doc.scopeSelected', 'Selected licence types'), + }, + ]} + /> + {!appliesToAll && ( + a.sortOrder - b.sortOrder) + .map((lt) => ({ value: lt.id, label: localized(lt.name) || lt.key }))} + value={scopeIds} + onChange={setScopeIds} + searchable + placeholder={t('certReq.doc.scopePlaceholder', 'Choose licence types')} + description={t( + 'certReq.doc.scopeHelp', + 'Only applicants who declared one of these as a mode of operation are asked for it.', + )} + /> + )} + + )} - v && setDraft((d) => ({ ...d, applicationKind: v as ApplicationKind }))} + allowDeselect={false} + disabled={!isNew} + description={!isNew ? t('certReq.doc.kindLocked', 'Application kind cannot change once created') : undefined} + /> + )} - {draft.mode === 'CONDITIONAL' && ( + {!personal && ( + a.sortOrder - b.sortOrder) + .map((lt) => ({ value: lt.id, label: localized(lt.name) || lt.key })), + ]} + value={licenseTypeFilter} + onChange={(value) => { + setLicenseTypeFilter(value); + // A narrower list can be shorter than the page you were on. + setPageIndex(0); + }} + searchable + clearable + w={240} + /> + {isFiltered && ( + + )} + + + + + + setEditing(null)} + requirement={editing?.group?.rows[0] ?? null} + defaultApplicationKind="NEW" + onSave={handleSave} + palette={undefined} + conditionTargets={[]} + saving={creating || updating} + personal + licenseTypes={licenseTypes?.items ?? []} + scope={editing?.group?.scope ?? []} + /> + + setDeleteTarget(null)} + title={t('certReq.doc.delete', 'Delete document requirement')} + size="sm" + > + + + {t( + 'certReq.personal.deleteWarning', + 'The slot disappears from every applicant’s My Documents. Files already uploaded are kept, but nobody can reach them.', + )} + + + {t('certReq.doc.deleteConfirm', 'Remove "{{name}}"?', { + name: deleteTarget + ? localized(deleteTarget.rows[0].name) || deleteTarget.key + : '', + })} + + + + + + + + + ); +} diff --git a/apps/backoffice/src/app/features/certificate-requirements/components/SectionEditorDrawer.tsx b/apps/backoffice/src/app/features/certificate-requirements/components/SectionEditorDrawer.tsx index 6110b8358..5bdd062f7 100644 --- a/apps/backoffice/src/app/features/certificate-requirements/components/SectionEditorDrawer.tsx +++ b/apps/backoffice/src/app/features/certificate-requirements/components/SectionEditorDrawer.tsx @@ -72,7 +72,10 @@ export function SectionEditorDrawer({ ? t('certReq.section.keyHelp', 'Letters, numbers and underscores only') : t('certReq.section.keyLocked', 'Key cannot change once created') } - onChange={(e) => setDraft((d) => ({ ...d, key: e.currentTarget.value }))} + onChange={(e) => { + const { value } = e.currentTarget; + setDraft((d) => ({ ...d, key: value })); + }} /> setDraft((d) => ({ ...d, group: e.currentTarget.value || undefined }))} + onChange={(e) => { + const { value } = e.currentTarget; + setDraft((d) => ({ ...d, group: value || undefined })); + }} /> ; + +function emptyDraft(sortOrder: number): Draft { + return { + roleKey: '', + name: { en: '', am: '' }, + minCount: 1, + maxCount: null, + requiresExperience: false, + minYearsExperience: null, + requiredEvidence: [], + sortOrder, + }; +} + +/** + * The staff a licence type demands, and the evidence each of them must + * supply — the wizard's Staff step, which lives in its own table rather than + * in `formSchema` and so had no editor at all: an administrator could see FF + * asking for two ERB-certified transit employees but could not change it + * without a re-seed. + * + * Rows save on confirm, like the document requirements tab — deliberately not + * folded into the schema draft above, whose "Save schema" button replaces one + * jsonb column in a single PUT. + */ +export function StaffRolesCard({ licenseType }: { licenseType: LicenseType }) { + const { t } = useTranslation(); + const localized = useLocalized(); + const run = useRequirementActions(); + + const { data, isLoading } = useGetStaffRoleRequirementsQuery(); + const [createRole, { isLoading: creating }] = useCreateStaffRoleRequirementMutation(); + const [updateRole, { isLoading: updating }] = useUpdateStaffRoleRequirementMutation(); + const [deleteRole] = useDeleteStaffRoleRequirementMutation(); + + const [editing, setEditing] = useState<{ role: StaffRoleRequirement | null } | null>(null); + const [deleteTarget, setDeleteTarget] = useState(null); + + const roles = useMemo( + () => + (data?.items ?? []) + .filter((r) => r.licenseTypeId === licenseType.id) + .slice() + .sort((a, b) => a.sortOrder - b.sortOrder), + [data, licenseType.id], + ); + + async function handleSave(draft: Draft) { + const target = editing?.role; + const ok = await run( + () => + target + ? updateRole({ id: target.id, ...draft }).unwrap() + : createRole({ ...draft, licenseTypeId: licenseType.id }).unwrap(), + target + ? t('certReq.staff.updated', 'Staff role updated') + : t('certReq.staff.created', 'Staff role added'), + ); + if (ok) setEditing(null); + } + + async function confirmDelete() { + if (!deleteTarget) return; + const ok = await run( + () => deleteRole(deleteTarget.id).unwrap(), + t('certReq.staff.deleted', 'Staff role removed'), + ); + if (ok) setDeleteTarget(null); + } + + return ( + + +
+ {t('certReq.staff.title', 'Staff roles')} + + {t( + 'certReq.staff.subtitle', + 'The Staff step of this licence type: who the applicant must register and the evidence each of them uploads. Saved on confirm, separately from the sections above.', + )} + +
+ +
+ + {isLoading ? ( + + ) : roles.length === 0 ? ( + + {t('certReq.staff.empty', 'No staff roles — this licence type has no Staff step.')} + + ) : ( + + {roles.map((role) => ( + + +
+ + {localized(role.name) || role.roleKey} + + {role.maxCount != null && role.maxCount === role.minCount + ? t('certReq.staff.exactly', 'exactly {{n}}', { n: role.minCount }) + : t('certReq.staff.range', 'min {{min}}{{max}}', { + min: role.minCount, + max: role.maxCount != null ? ` · max ${role.maxCount}` : '', + })} + + {role.requiresExperience && ( + + {t('certReq.staff.experience', 'experience')} + {role.minYearsExperience ? ` · ${role.minYearsExperience}y` : ''} + + )} + + + key: {role.roleKey} + {role.requiredEvidence.length > 0 && + ` · ${role.requiredEvidence + .map((e) => `${e.docKey}${e.mandatory ? '*' : ''}`) + .join(', ')}`} + +
+ + setEditing({ role })}> + + + setDeleteTarget(role)}> + + + +
+
+ ))} +
+ )} + + setEditing(null)} + role={editing?.role ?? null} + nextSortOrder={roles.length + 1} + onSave={handleSave} + saving={creating || updating} + /> + + setDeleteTarget(null)} + title={t('certReq.staff.delete', 'Delete staff role')} + size="sm" + > + + + {t('certReq.staff.deleteConfirm', 'Remove "{{name}}" from this licence type\'s Staff step?', { + name: deleteTarget ? localized(deleteTarget.name) || deleteTarget.roleKey : '', + })} + + + + + + + +
+ ); +} + +function StaffRoleEditorDrawer({ + opened, + onClose, + role, + nextSortOrder, + onSave, + saving, +}: { + opened: boolean; + onClose: () => void; + /** Null = adding a new role. */ + role: StaffRoleRequirement | null; + nextSortOrder: number; + onSave: (draft: Draft) => void; + saving: boolean; +}) { + const { t } = useTranslation(); + const [draft, setDraft] = useState(emptyDraft(nextSortOrder)); + const [keyError, setKeyError] = useState(null); + const isNew = !role; + + useEffect(() => { + if (!opened) return; + setDraft( + role + ? { + roleKey: role.roleKey, + name: { ...role.name }, + minCount: role.minCount, + maxCount: role.maxCount, + requiresExperience: role.requiresExperience, + minYearsExperience: role.minYearsExperience, + requiredEvidence: role.requiredEvidence.map((e) => ({ ...e, label: { ...e.label } })), + sortOrder: role.sortOrder, + } + : emptyDraft(nextSortOrder), + ); + setKeyError(null); + }, [opened, role, nextSortOrder]); + + function patchEvidence(index: number, patch: Partial) { + setDraft((d) => ({ + ...d, + requiredEvidence: d.requiredEvidence.map((e, i) => (i === index ? { ...e, ...patch } : e)), + })); + } + + function save() { + const roleKey = draft.roleKey.trim(); + if (!ROLE_KEY_PATTERN.test(roleKey)) { + setKeyError( + t( + 'certReq.staff.keyInvalid', + 'Key must start with a letter and contain only letters, numbers, underscores', + ), + ); + return; + } + if (!draft.name.en?.trim()) return; + // An evidence row with no docKey renders an upload slot nothing can be + // attached to, so it is dropped rather than saved half-filled. + onSave({ + ...draft, + roleKey, + requiredEvidence: draft.requiredEvidence.filter((e) => e.docKey.trim()), + }); + } + + return ( + + {isNew ? t('certReq.staff.add', 'Add staff role') : t('certReq.staff.edit', 'Edit staff role')} + + } + > + + { + const { value } = e.currentTarget; + setDraft((d) => ({ ...d, roleKey: value })); + }} + /> + + setDraft((d) => ({ ...d, name: v }))} + /> + + + setDraft((d) => ({ ...d, minCount: typeof v === 'number' ? v : 0 }))} + description={t('certReq.staff.minCountHelp', '0 makes the role optional')} + /> + setDraft((d) => ({ ...d, maxCount: typeof v === 'number' ? v : null }))} + description={t('certReq.staff.maxCountHelp', 'Leave empty for no limit')} + /> + + + { + const { checked } = e.currentTarget; + setDraft((d) => ({ + ...d, + requiresExperience: checked, + minYearsExperience: checked ? d.minYearsExperience : null, + })); + }} + /> + + {draft.requiresExperience && ( + + setDraft((d) => ({ ...d, minYearsExperience: typeof v === 'number' ? v : null })) + } + /> + )} + + setDraft((d) => ({ ...d, sortOrder: typeof v === 'number' ? v : 0 }))} + /> + + + + {t( + 'certReq.staff.evidenceHelp', + 'One upload slot per document, asked of every person registered in this role.', + )} + + + {draft.requiredEvidence.map((evidence, i) => ( + + + + patchEvidence(i, { docKey: e.currentTarget.value })} + style={{ flex: 1 }} + /> + patchEvidence(i, { label: v })} + style={{ flex: 2 }} + /> + + + patchEvidence(i, { mandatory: e.currentTarget.checked })} + /> + + + ))} + + + + + + + + + + ); +} diff --git a/apps/backoffice/src/app/features/certificate-requirements/pages/CertificateRequirementsPage.tsx b/apps/backoffice/src/app/features/certificate-requirements/pages/CertificateRequirementsPage.tsx index 86352bb97..f6ccf9c36 100644 --- a/apps/backoffice/src/app/features/certificate-requirements/pages/CertificateRequirementsPage.tsx +++ b/apps/backoffice/src/app/features/certificate-requirements/pages/CertificateRequirementsPage.tsx @@ -1,9 +1,16 @@ import { useEffect, useState } from 'react'; import { Container, Select, Stack, Tabs } from '@mantine/core'; -import { IconAlertCircle, IconFileText, IconFiles, IconSettings } from '@tabler/icons-react'; +import { + IconAdjustments, + IconAlertCircle, + IconFileText, + IconFiles, + IconSettings, +} from '@tabler/icons-react'; import { useTranslation } from 'react-i18next'; import { ErrorState, PageHeader, PageLoader } from '@ema-platform/ui'; import { extractErrorMessage, useGetLicenseTypesQuery, useLocalized } from '@ema-platform/api'; +import { BehaviorTab } from '../components/BehaviorTab'; import { DocumentRequirementsTab } from '../components/DocumentRequirementsTab'; import { FormSchemaTab } from '../components/FormSchemaTab'; @@ -78,6 +85,9 @@ export function CertificateRequirementsPage() { }> {t('certReq.tabDocuments', 'Document requirements')} + }> + {t('certReq.tabBehavior', 'Behaviour')} + @@ -87,6 +97,10 @@ export function CertificateRequirementsPage() { + + + + )} diff --git a/apps/backoffice/src/app/features/configuration/pages/ConfigurationPage/index.tsx b/apps/backoffice/src/app/features/configuration/pages/ConfigurationPage/index.tsx index a28c98db2..5fe270643 100644 --- a/apps/backoffice/src/app/features/configuration/pages/ConfigurationPage/index.tsx +++ b/apps/backoffice/src/app/features/configuration/pages/ConfigurationPage/index.tsx @@ -24,6 +24,7 @@ import { IconHash, IconInfoCircle, IconAnchor, + IconId, } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; import { @@ -35,6 +36,9 @@ import { PageLoader, } from "@ema-platform/ui"; import { LocationPage } from "../../../location/pages/LocationPage"; +// Lives with the document-requirement editor it reuses; shown here because a +// document required for every licence is configuration, not one type's form. +import { PersonalDocumentsCard } from "../../../certificate-requirements/components/PersonalDocumentsCard"; import { CertificationPage } from "../../../certification/pages/CertificationPage"; import { NumberFormatTab } from "../../components/NumberFormatTab"; import { RankDepartmentTab } from "./RankDepartmentTab"; @@ -406,6 +410,9 @@ export function ConfigurationPage() { }> {t("configuration.ranksTab", "Ranks & Departments")} + }> + {t("configuration.personalDocumentsTab", "Personal Documents")} + @@ -427,6 +434,10 @@ export function ConfigurationPage() { + + + + ); diff --git a/apps/backoffice/src/app/features/license-review/components/DocumentsTab.tsx b/apps/backoffice/src/app/features/license-review/components/DocumentsTab.tsx index 7d55d4bce..89cf9681f 100644 --- a/apps/backoffice/src/app/features/license-review/components/DocumentsTab.tsx +++ b/apps/backoffice/src/app/features/license-review/components/DocumentsTab.tsx @@ -174,6 +174,7 @@ export function DocumentsTab({ {attachments.map((attachment) => { const file = attachment.files?.[0]; + const fromVault = Boolean(attachment.copiedFromAttachmentId); const flagged = attachment.documentKey in flags; const verdict = verdictFor(attachment.documentKey); const pendingReject = attachment.documentKey in rejecting; @@ -199,6 +200,14 @@ export function DocumentsTab({ requirementByKey.get(attachment.documentKey)?.name, ) || attachment.documentKey} + {/* Filed from the applicant's own document vault rather + than uploaded against this application — worth knowing + when the same file turns up on several files. */} + {fromVault && ( + + {t("review.documents.fromVault", "From My Documents")} + + )} {verdict && ( (''); + const [examinationFee, setExaminationFee] = useState(''); + const [certificateFee, setCertificateFee] = useState(''); + + const examined = licenseType?.requiresExamination ?? false; useEffect(() => { if (!licenseType) return; + setEligibilityFee( + licenseType.feeEligibility == null ? '' : Number(licenseType.feeEligibility), + ); + setExaminationFee( + licenseType.feeExamination == null ? '' : Number(licenseType.feeExamination), + ); + setCertificateFee( + licenseType.feeCertificate == null ? '' : Number(licenseType.feeCertificate), + ); setChargeable(licenseType.feeNewApplication !== null); setNewFee( licenseType.feeNewApplication === null @@ -221,6 +237,14 @@ function FeeEditModal({ feeNewApplication: chargeable ? Number(newFee) : null, feeRenewal: chargeable && !sameAsNew ? Number(renewalFee) : null, feeCurrency: currency.trim() || 'ETB', + // Only sent for examined types; otherwise the columns stay untouched. + ...(examined + ? { + feeEligibility: eligibilityFee === '' ? null : Number(eligibilityFee), + feeExamination: examinationFee === '' ? null : Number(examinationFee), + feeCertificate: certificateFee === '' ? null : Number(certificateFee), + } + : {}), }).unwrap(); notify.success( t('paymentConfig.modal.updated', { @@ -325,6 +349,68 @@ function FeeEditModal({ )} + {examined && ( + <> + } + > + + {t( + 'paymentConfig.modal.examinedNotice', + 'This certificate is earned by examination, so it is charged in three stages. Unlike the fees above, these are not fixed at approval — a change applies to candidates already part-way through. Clearing one while candidates are waiting to pay it will be refused.', + )} + + + + setEligibilityFee(v === '' ? '' : Number(v))} + min={0} + max={9_999_999_999} + thousandSeparator="," + allowNegative={false} + decimalScale={2} + /> + + setExaminationFee(v === '' ? '' : Number(v))} + min={0} + max={9_999_999_999} + thousandSeparator="," + allowNegative={false} + decimalScale={2} + /> + + setCertificateFee(v === '' ? '' : Number(v))} + min={0} + max={9_999_999_999} + thousandSeparator="," + allowNegative={false} + decimalScale={2} + /> + + )} + + )} + +
+ + + + ); + })} + + + setDeleteTarget(null)} + title={t('documents.personal.confirmDelete.title')} + size="sm" + centered + > + + + {t('documents.personal.confirmDelete.body', { + name: deleteTarget?.originalName ?? '', + })} + + + + + + + + + ); +} diff --git a/apps/portal/src/app/features/documents/pages/DocumentVaultPage.tsx b/apps/portal/src/app/features/documents/pages/DocumentVaultPage.tsx index 172419552..efef94840 100644 --- a/apps/portal/src/app/features/documents/pages/DocumentVaultPage.tsx +++ b/apps/portal/src/app/features/documents/pages/DocumentVaultPage.tsx @@ -1,6 +1,54 @@ -import { Container } from '@mantine/core'; -import { FeatureUnavailable } from '@ema-platform/ui'; +import { useState } from 'react'; +import { + Anchor, + Badge, + Button, + Card, + Container, + Divider, + Group, + Loader, + SimpleGrid, + Stack, + Tabs, + Text, + ThemeIcon, + Title, +} from '@mantine/core'; +import { + IconAnchor, + IconCertificate, + IconEye, + IconHeartbeat, + IconIdBadge2, + IconPaperclip, +} from '@tabler/icons-react'; import { useTranslation } from 'react-i18next'; +import { useDateDisplayer } from '@ema-platform/shared'; +import { FilePreviewModal, notify } from '@ema-platform/ui'; +import { + extractErrorMessage, + useGetAttachmentsQuery, + useGetCertificateUrlMutation, + useGetMyLicensesQuery, + useGetMyMedicalCertificatesQuery, + useGetMySeaServiceRecordsQuery, + useGetMySeafarerDocumentsQuery, + useLazyGetMySeafarerDocumentDownloadQuery, + type SeafarerDocument, + type SeafarerRecordStatus, +} from '@ema-platform/api'; +import { LicenseCard, useRenewLicense } from '../../licensing/components/LicenseCard'; +import { PersonalDocumentSlots } from '../components/PersonalDocumentSlots'; + +/** What the viewer needs: the link, a caption, and how to render it. */ +type Preview = { url: string; title: string; mimeType?: string | null }; + +const RECORD_STATUS_COLOR: Record = { + SUBMITTED: 'blue', + VERIFIED: 'teal', + REJECTED: 'red', +}; /** * Placeholder until this feature has a backend. @@ -8,14 +56,403 @@ import { useTranslation } from 'react-i18next'; * This page previously rendered invented figures/records that were * indistinguishable from real ones. */ +function RecordFiles({ + ownerType, + ownerId, + onPreview, +}: { + ownerType: 'SEA_SERVICE_RECORD' | 'MEDICAL_CERTIFICATE' | 'SEAFARER_REGISTRATION'; + ownerId: string; + onPreview: (preview: Preview) => void; +}) { + const { t } = useTranslation(); + const { data, isLoading } = useGetAttachmentsQuery({ ownerType, ownerId }); + const files = (data ?? []).flatMap((a) => a.files); + + if (isLoading) return ; + if (files.length === 0) + return ( + + {t('documents.files.none')} + + ); + + return ( + + {files.map((file) => ( + + + {file.url ? ( + + onPreview({ + url: file.url as string, + title: file.originalName, + mimeType: file.mimeType, + }) + } + > + {file.originalName} + + ) : ( + {file.originalName} + )} + + ))} + + ); +} + +function FieldRow({ label, value }: { label: string; value: string }) { + return ( + + + {label} + + + {value} + + + ); +} + +/** Seaman Book / BTC — issued on their own workflow, not as licences. */ +function IssuedDocumentCard({ + document, + onPreview, +}: { + document: SeafarerDocument; + onPreview: (preview: Preview) => void; +}) { + const { t } = useTranslation(); + const showDate = useDateDisplayer(); + const [download, { isFetching }] = useLazyGetMySeafarerDocumentDownloadQuery(); + const issued = document.status === 'ISSUED'; + + async function open() { + try { + const { url } = await download(document.id).unwrap(); + onPreview({ url, title: t(`documents.kind.${document.kind}`) }); + } catch (err) { + notify.error(extractErrorMessage(err), t('documents.openFailed')); + } + } + + return ( + + + + + + +
+ + {t(`documents.kind.${document.kind}`)} + + + {document.documentNumber ?? document.requestNumber} + +
+
+ + {t(`documents.documentStatus.${document.status}`, { defaultValue: document.status })} + +
+ + + + + {document.issueDate && ( + + )} + {document.expiryDate && ( + + )} + + + +
+ ); +} + export function DocumentVaultPage() { const { t } = useTranslation(); + const showDate = useDateDisplayer(); + const [preview, setPreview] = useState(null); + + const { data: licences, isLoading: loadingLicences } = useGetMyLicensesQuery(); + const { data: issuedDocuments } = useGetMySeafarerDocumentsQuery(); + const { data: medicals, isLoading: loadingMedicals } = useGetMyMedicalCertificatesQuery(); + const { data: seaService, isLoading: loadingSeaService } = useGetMySeaServiceRecordsQuery(); + + const [getCertificateUrl, { isLoading: isDownloadingCert }] = useGetCertificateUrlMutation(); + const { renewLicense, isRenewing } = useRenewLicense(); + + async function openCertificate(licenseId: string) { + try { + const { url } = await getCertificateUrl(licenseId).unwrap(); + setPreview({ url, title: t('licensing.card.downloadCertificate') }); + } catch (err) { + notify.error(extractErrorMessage(err), t('documents.openFailed')); + } + } + + const licenceItems = licences?.items ?? []; + const issued = [issuedDocuments?.seamanBook, issuedDocuments?.btc].filter( + (d): d is SeafarerDocument => Boolean(d), + ); return ( - +
+ {t('documents.title')} + + {t('documents.subtitle')} + +
+ + + + }> + {t('documents.tabs.license')} + + }> + {t('documents.tabs.medical')} + + }> + {t('documents.tabs.seaService')} + + }> + {t('documents.tabs.personal')} + + + + {/* ── Licences and EMA-issued documents ───────────────────────── */} + + + {issued.length > 0 && ( +
+ + {t('documents.issuedTitle')} + + + {issued.map((document) => ( + + ))} + +
+ )} + +
+ + {t('documents.licensesTitle')} + + {loadingLicences ? ( + + ) : licenceItems.length === 0 ? ( + + {t('documents.empty.licenses')} + + ) : ( + // Two per row, not three: licence type names run long + // ("Multimodal Transport Operator License") and a third + // column truncates them. + + {licenceItems.map((licence) => ( + openCertificate(licence.id)} + onRenew={() => renewLicense(licence)} + /> + ))} + + )} +
+
+
+ + {/* ── Medical certificates ────────────────────────────────────── */} + + {loadingMedicals ? ( + + ) : (medicals ?? []).length === 0 ? ( + + {t('documents.empty.medical')} + + ) : ( + + {(medicals ?? []).map((record) => ( + + + + + + +
+ + {record.issuerName} + + {record.certificateNumber && ( + + {t('seaRecords.columns.certNumber', { + number: record.certificateNumber, + })} + + )} +
+
+ + {t(`seaRecords.columns.recordStatus.${record.status}`, { + defaultValue: record.status, + })} + +
+ + + + + + + + + + + +
+ ))} +
+ )} +
+ + {/* ── Sea service records ─────────────────────────────────────── */} + + {loadingSeaService ? ( + + ) : (seaService ?? []).length === 0 ? ( + + {t('documents.empty.seaService')} + + ) : ( + + {(seaService ?? []).map((record) => ( + + + + + + +
+ + {record.vesselName} + + {record.imoNumber && ( + + {t('seaRecords.columns.imo', { number: record.imoNumber })} + + )} +
+
+ + {t(`seaRecords.columns.recordStatus.${record.status}`, { + defaultValue: record.status, + })} + +
+ + + + + + + + + + + +
+ ))} +
+ )} +
+ + {/* ── The applicant's own document vault ──────────────────────── */} + + {/* Owned by the profile, not by a registration or an application: + the slots are configured in the backoffice and the files travel + with the person. */} + + +
+ + + setPreview(null)} + url={preview?.url ?? ''} + title={preview?.title} + mimeType={preview?.mimeType} + labels={{ + unsupported: t('documents.preview.unsupported'), + openInNewTab: t('documents.preview.openInNewTab'), + close: t('documents.preview.close'), + }} />
); diff --git a/apps/portal/src/app/features/licensing/components/DocumentSlots.tsx b/apps/portal/src/app/features/licensing/components/DocumentSlots.tsx index 06a137b46..d92724618 100644 --- a/apps/portal/src/app/features/licensing/components/DocumentSlots.tsx +++ b/apps/portal/src/app/features/licensing/components/DocumentSlots.tsx @@ -23,7 +23,7 @@ import { type DocumentRequirement, } from '@ema-platform/api'; import { useTranslation } from 'react-i18next'; -import { PdfPreviewModal } from '@ema-platform/ui'; +import { FilePreviewModal } from '@ema-platform/ui'; const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024; @@ -69,7 +69,11 @@ export function DocumentSlots({ const { t } = useTranslation(); const [busy, setBusy] = useState(null); const [error, setError] = useState(null); - const [preview, setPreview] = useState<{ url: string; title: string } | null>( + const [preview, setPreview] = useState<{ + url: string; + title: string; + mimeType?: string | null; + } | null>( null, ); const resetRefs = useRef void>>({}); @@ -111,7 +115,8 @@ export function DocumentSlots({ {required.map((requirement) => { const existing = attachments.find((a) => a.documentKey === requirement.key); const uploaded = Boolean(existing?.files?.length); - const fileUrl = existing?.files?.[0]?.url; + const files = existing?.files ?? []; + const fromVault = Boolean(existing?.copiedFromAttachmentId); const flagRemark = flagged[requirement.key]; const locked = readOnly || @@ -154,18 +159,25 @@ export function DocumentSlots({ {t('licensing.documents.uploaded')} )} + {/* Filled from the applicant's own vault rather than + uploaded here — without saying so, a file they never + attached to this application looks like a mistake. */} + {fromVault && !flagRemark && ( + + {t('licensing.documents.fromVault')} + + )} {requirement.description && ( {localized(requirement.description)} )} - {existing?.files?.[0] && ( - - {existing.files[0].originalName} ·{' '} - {(existing.files[0].sizeBytes / 1024).toFixed(0)} KB + {files.map((file) => ( + + {file.originalName} · {(Number(file.sizeBytes) / 1024).toFixed(0)} KB - )} + ))} {flagRemark && ( {t('licensing.documents.officerRemark', { name: flagRemark })} @@ -174,20 +186,26 @@ export function DocumentSlots({ - {fileUrl && ( - - )} + {files + .filter((file) => file.url) + .map((file, index) => ( + + ))} {!locked && ( { @@ -222,11 +240,12 @@ export function DocumentSlots({ ); })} - setPreview(null)} url={preview?.url ?? ''} title={preview?.title} + mimeType={preview?.mimeType} /> ); diff --git a/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx b/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx index 2a75eeae5..16e46e7cb 100644 --- a/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx +++ b/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { ActionIcon, @@ -44,6 +44,7 @@ import { useAddStaffMutation, useCreateApplicationMutation, useGetApplicationQuery, + useFillApplicationDocumentsFromVaultMutation, useGetAttachmentsQuery, useGetLicenseTypeRequirementsQuery, useGetMyVesselsQuery, @@ -430,6 +431,46 @@ export function LicenseApplicationPage() { if (active > steps.length - 1) setActive(Math.max(0, steps.length - 1)); }, [active, steps.length]); + /** + * Fills the document slots from the applicant's own vault the first time + * they reach that step. + * + * Someone who already keeps their passport under My Documents should find it + * here rather than being asked to go and fetch the same file again. The + * server only fills empty slots, so this cannot overwrite anything they + * uploaded, and calling it twice costs one query. + */ + const [fillFromVault] = useFillApplicationDocumentsFromVaultMutation(); + const filledForApplication = useRef(null); + useEffect(() => { + const status = application?.status; + const editable = + status === "DRAFT" || + status === "RESUBMIT_REQUIRED" || + (status === "SUBMITTED" && !application?.assignedOfficerId); + if (steps[active]?.kind !== "documents" || !appId || !editable) return; + if (filledForApplication.current === appId) return; + filledForApplication.current = appId; + + fillFromVault(appId) + .unwrap() + .then((result) => { + // Nothing was copied: no need to disturb the queries. + if (result.filled.length) refetchAttachments(); + }) + // A vault that cannot be read is not a reason to block the form — the + // applicant can still upload by hand. + .catch(() => undefined); + }, [ + steps, + active, + appId, + application?.status, + application?.assignedOfficerId, + fillFromVault, + refetchAttachments, + ]); + if (loadingConfig || !config || !appId || !application) { return ; } @@ -578,6 +619,7 @@ export function LicenseApplicationPage() { const currentStep = steps[active]; + /** * Checks one step before moving past it. * diff --git a/apps/portal/src/app/features/seafarer-registration/components/RegistrationDocuments.tsx b/apps/portal/src/app/features/seafarer-registration/components/RegistrationDocuments.tsx index e1fb715e3..cc3c19b21 100644 --- a/apps/portal/src/app/features/seafarer-registration/components/RegistrationDocuments.tsx +++ b/apps/portal/src/app/features/seafarer-registration/components/RegistrationDocuments.tsx @@ -2,38 +2,69 @@ import { useRef, useState } from 'react'; import { Alert, Badge, Button, Card, FileButton, Group, Loader, Stack, Text } from '@mantine/core'; import { IconAlertTriangle, IconCheck, IconFileUpload } from '@tabler/icons-react'; import { - SEAFARER_REGISTRATION_DOCUMENTS, - isEthiopianNationality, + conditionHolds, uploadDocument, + useLocalized, type Attachment, + type DocumentRequirement, } from '@ema-platform/api'; const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024; -/** The document slots a registration asks for. */ -export function documentSlots(passportDeclared: boolean, nationality?: string | null) { - const ethiopian = isEthiopianNationality(nationality); - return SEAFARER_REGISTRATION_DOCUMENTS.map((d) => ({ - ...d, - isRequired: d.required === 'passport' ? passportDeclared : d.required === 'ethiopian' ? ethiopian : d.required, - })).filter((d) => (d.required !== 'passport' || passportDeclared) && (d.required !== 'ethiopian' || ethiopian)); +/** A configured requirement, with whether this applicant must supply it. */ +export type RegistrationSlot = DocumentRequirement & { isRequired: boolean }; + +/** + * The document slots a registration asks for. + * + * Configuration, not a constant: these are `document_requirements` rows + * against the SEAFARER_REGISTRATION licence type, the same ones the backoffice + * edits and the server validates against. A conditional requirement — the + * passport copy, wanted once a passport number is declared — is evaluated + * against the answers under `identity`, exactly as the server does it, so the + * wizard and the submit check can never disagree about what is being asked + * for. + */ +export function documentSlots( + requirements: DocumentRequirement[], + answers: Record, +): RegistrationSlot[] { + const context = { identity: answers, personal: answers } as Record< + string, + Record + >; + return requirements + .filter( + (requirement) => + requirement.mode !== 'CONDITIONAL' || + conditionHolds(requirement.conditionExpression, context), + ) + .map((requirement) => ({ + ...requirement, + isRequired: + requirement.mode === 'ALWAYS' || + (requirement.mode === 'CONDITIONAL' && + conditionHolds(requirement.conditionExpression, context)), + })); } export function RegistrationDocuments({ registrationId, - passportDeclared, - nationality, + requirements, + answers, attachments, readOnly, onUploaded, }: { registrationId: string; - passportDeclared: boolean; - nationality?: string | null; + requirements: DocumentRequirement[]; + /** The registration's answers, for evaluating conditional requirements. */ + answers: Record; attachments: Attachment[]; readOnly?: boolean; onUploaded: () => void; }) { + const localized = useLocalized(); const [busy, setBusy] = useState(null); const [error, setError] = useState(null); const resetRefs = useRef void>>({}); @@ -66,9 +97,10 @@ export function RegistrationDocuments({ {error} )} - {documentSlots(passportDeclared, nationality).map((slot) => { + {documentSlots(requirements, answers).map((slot) => { const existing = attachments.find((a) => a.documentKey === slot.key); const uploaded = Boolean(existing?.files?.length); + const fromVault = Boolean(existing?.copiedFromAttachmentId); return ( - {slot.name} + {localized(slot.name)} {!slot.isRequired && ( @@ -95,10 +127,16 @@ export function RegistrationDocuments({ uploaded )} + {/* Copied from My Documents rather than uploaded here. */} + {fromVault && ( + + from My Documents + + )} {slot.description && ( - {slot.description} + {localized(slot.description)} )} {existing?.files?.[0] && ( @@ -119,7 +157,7 @@ export function RegistrationDocuments({ if (r) resetRefs.current[slot.key] = r; }} onChange={(file) => handle(slot.key, file)} - accept={slot.accept} + accept={slot.allowedMimeTypes?.join(',')} > {(props) => (