mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-07 13:05:46 +00:00
Merge branch 'createNewLicenseType' of https://github.com/Tria-plc/emaui into createNewLicenseType
This commit is contained in:
@@ -135,6 +135,21 @@ export function BlockPropertiesPanel({
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<NumberInput
|
||||
label={t('designer.blockPage', 'Page')}
|
||||
description={t('designer.blockPageHint', 'Which sheet of the document this block prints on')}
|
||||
value={(block.page ?? 0) + 1}
|
||||
onChange={(value) => {
|
||||
const index = Math.max(0, Math.min(63, Math.floor(Number(value) || 1) - 1));
|
||||
// Omitted on the first page so a single-page design stays as it was.
|
||||
const { page: _page, ...rest } = block;
|
||||
onChange(index > 0 ? { ...rest, page: index } : rest);
|
||||
}}
|
||||
min={1}
|
||||
max={64}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<Group gap="xs" grow>
|
||||
<NumberInput
|
||||
label={t('designer.blockX', 'X (%)')}
|
||||
|
||||
@@ -84,9 +84,14 @@ export function DesignerToolbar({
|
||||
<Text size="sm" fw={600}>
|
||||
{validityDays != null
|
||||
? t('designer.validityDays', '{{count}} days', { count: validityDays })
|
||||
: t('designer.validityMonths', '{{count}} months', {
|
||||
count: validityMonths,
|
||||
})}
|
||||
: validityMonths
|
||||
? t('designer.validityMonths', '{{count}} months', {
|
||||
count: validityMonths,
|
||||
})
|
||||
: // A zero-month term is not honoured at issuance — the
|
||||
// renderer falls back to twelve months — so say what
|
||||
// will actually be printed rather than "does not expire".
|
||||
t('designer.validityNone', 'No term set — issues for 12 months')}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('designer.validityEditedOn', '— set on Certificate Requirements → Behaviour')}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Box, Paper, Text } from '@mantine/core';
|
||||
import { IconPhoto } from '@tabler/icons-react';
|
||||
import { Box, Button, Group, Paper, Text } from '@mantine/core';
|
||||
import { IconPhoto, IconPlus } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { TemplateFieldPlacement, TemplateLogoPlacement } from '@ema-platform/api';
|
||||
import { pageIndexOf } from '../config/layout-compiler';
|
||||
|
||||
interface Props {
|
||||
backgroundUrl: string;
|
||||
@@ -16,6 +17,11 @@ interface Props {
|
||||
onSelect: (id: string | null) => void;
|
||||
onChange: (placements: TemplateFieldPlacement[]) => void;
|
||||
disabled: boolean;
|
||||
/** Zero-based page being shown; only that page's blocks are drawn. */
|
||||
page: number;
|
||||
pageCount: number;
|
||||
onPageChange: (page: number) => void;
|
||||
onAddPage: () => void;
|
||||
}
|
||||
|
||||
/** A4 aspect ratio, the fallback for a version with no custom page size. */
|
||||
@@ -71,9 +77,14 @@ export function TemplateCanvas({
|
||||
onSelect,
|
||||
onChange,
|
||||
disabled,
|
||||
page,
|
||||
pageCount,
|
||||
onPageChange,
|
||||
onAddPage,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const pageRef = useRef<HTMLDivElement>(null);
|
||||
const visible = placements.filter((block) => pageIndexOf(block) === page);
|
||||
const [drag, setDrag] = useState<DragState | null>(null);
|
||||
|
||||
// Held in a ref as well: the pointer handlers are bound to the window for the
|
||||
@@ -214,6 +225,31 @@ export function TemplateCanvas({
|
||||
)}
|
||||
</Text>
|
||||
|
||||
{/* One tab per sheet. A single-page certificate shows only "Page 1" and
|
||||
the add button; a booklet (the Seaman Book) switches between its
|
||||
pages here, each positioning its own blocks. */}
|
||||
<Group gap={6} mb="xs">
|
||||
{Array.from({ length: pageCount }, (_, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
size="compact-xs"
|
||||
variant={index === page ? 'filled' : 'default'}
|
||||
onClick={() => onPageChange(index)}
|
||||
>
|
||||
{t('designer.pageTab', 'Page {{n}}', { n: index + 1 })}
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconPlus size={12} />}
|
||||
disabled={disabled}
|
||||
onClick={onAddPage}
|
||||
>
|
||||
{t('designer.addPage', 'Add page')}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Box
|
||||
ref={pageRef}
|
||||
onPointerDown={() => onSelect(null)}
|
||||
@@ -256,7 +292,7 @@ export function TemplateCanvas({
|
||||
/>
|
||||
)}
|
||||
|
||||
{placements.map((block) => {
|
||||
{visible.map((block) => {
|
||||
const isSelected = block.id === selectedId;
|
||||
const isImage = block.type === 'image';
|
||||
return (
|
||||
|
||||
@@ -51,7 +51,7 @@ export function TemplateEditor({
|
||||
<Text size="xs">
|
||||
{t(
|
||||
'designer.publishedLocked',
|
||||
'This version is live and cannot be edited — certificates have been issued from it. Create a new version to make changes.',
|
||||
'This version is live and cannot be edited — certificates are issued from it. Create a new version to make changes.',
|
||||
)}
|
||||
</Text>
|
||||
</Paper>
|
||||
|
||||
@@ -102,7 +102,16 @@ export function compileLayoutToHbs(input: {
|
||||
? ` <img class="ema-background" src="${escapeHtml(input.backgroundUrl)}" alt="" />\n`
|
||||
: '';
|
||||
|
||||
const blocks = input.fieldPlacements.map(blockHtml).join('');
|
||||
const logo = logoHtml(input.logoUrl, input.logoPlacement);
|
||||
|
||||
// Same grouping as the server: one page box per page index, each carrying
|
||||
// the background and logo, so a booklet previews as the sheets it prints.
|
||||
const body = groupByPage(input.fieldPlacements)
|
||||
.map(
|
||||
(blocks) =>
|
||||
` <div class="ema-page">\n${background}${logo}${blocks.map(blockHtml).join('')} </div>\n`,
|
||||
)
|
||||
.join('');
|
||||
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
@@ -113,6 +122,10 @@ export function compileLayoutToHbs(input: {
|
||||
html, body { margin: 0; padding: 0; height: 100%; }
|
||||
body { font-family: "Helvetica Neue", Arial, sans-serif; }
|
||||
.ema-page { position: relative; width: 100%; height: 100vh; overflow: hidden; }
|
||||
/* Every page but the last starts a new sheet. Applied to the element
|
||||
rather than between them so a single-page design prints one sheet
|
||||
with no trailing blank. */
|
||||
.ema-page + .ema-page { page-break-before: always; break-before: page; }
|
||||
.ema-background {
|
||||
position: absolute; inset: 0;
|
||||
width: 100%; height: 100%;
|
||||
@@ -122,9 +135,28 @@ export function compileLayoutToHbs(input: {
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="ema-page">
|
||||
${background}${logoHtml(input.logoUrl, input.logoPlacement)}${blocks} </div>
|
||||
</body>
|
||||
${body} </body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
/** Upper bound on pages, matching the server, so a bad value cannot explode the preview. */
|
||||
const MAX_PAGE_INDEX = 63;
|
||||
|
||||
/** A block's page, defaulting to the first and refusing a nonsense value. */
|
||||
export function pageIndexOf(block: Pick<TemplateFieldPlacement, 'page'>): number {
|
||||
const page = block.page;
|
||||
if (typeof page !== 'number' || !Number.isFinite(page) || page < 0) return 0;
|
||||
return Math.min(Math.floor(page), MAX_PAGE_INDEX);
|
||||
}
|
||||
|
||||
/**
|
||||
* Blocks split into pages, in page order, with no gaps — an empty page in
|
||||
* the middle of a booklet is a deliberate blank verso and is kept.
|
||||
*/
|
||||
export function groupByPage(placements: TemplateFieldPlacement[]): TemplateFieldPlacement[][] {
|
||||
const highest = placements.reduce((max, block) => Math.max(max, pageIndexOf(block)), 0);
|
||||
const pages: TemplateFieldPlacement[][] = Array.from({ length: highest + 1 }, () => []);
|
||||
for (const block of placements) pages[pageIndexOf(block)].push(block);
|
||||
return pages;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
TemplateFieldPlacement,
|
||||
TemplateLogoPlacement,
|
||||
} from '@ema-platform/api';
|
||||
import { pageIndexOf } from '../config/layout-compiler';
|
||||
|
||||
/** Stable ids for new blocks; `crypto.randomUUID` is not in every test env. */
|
||||
function blockId(): string {
|
||||
@@ -32,6 +33,10 @@ export function useTemplateDraft(templates: LicenseTemplate[]) {
|
||||
const [logoPlacement, setLogoPlacement] = useState<TemplateLogoPlacement>({});
|
||||
const [placements, setPlacements] = useState<TemplateFieldPlacement[]>([]);
|
||||
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(null);
|
||||
// The page the canvas is showing. Pages exist only through the blocks on
|
||||
// them, so a freshly added page is held here until its first block lands.
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
const [extraPages, setExtraPages] = useState(0);
|
||||
const editorRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const selected = useMemo(
|
||||
@@ -61,8 +66,19 @@ export function useTemplateDraft(templates: LicenseTemplate[]) {
|
||||
setLogoPlacement(selected.logoPlacement ?? {});
|
||||
setPlacements(selected.fieldPlacements ?? []);
|
||||
setSelectedBlockId(null);
|
||||
setCurrentPage(0);
|
||||
setExtraPages(0);
|
||||
}, [selected]);
|
||||
|
||||
/** Pages the design spans: the highest block page, plus any added and still empty. */
|
||||
const pageCount =
|
||||
placements.reduce((max, block) => Math.max(max, pageIndexOf(block)), 0) + 1 + extraPages;
|
||||
|
||||
const addPage = useCallback(() => {
|
||||
setExtraPages((n) => n + 1);
|
||||
setCurrentPage(pageCount);
|
||||
}, [pageCount]);
|
||||
|
||||
const isPublished = selected?.status === 'PUBLISHED';
|
||||
|
||||
// Compared as JSON because both are plain data the server round-trips; a
|
||||
@@ -101,12 +117,16 @@ export function useTemplateDraft(templates: LicenseTemplate[]) {
|
||||
*/
|
||||
const addBlock = useCallback(
|
||||
(variable: string | null, text?: string, kind: 'text' | 'image' = 'text') => {
|
||||
// Placed on the page being viewed; `page` is omitted on the first so a
|
||||
// single-page design stays byte-for-byte what it was before booklets.
|
||||
const page = currentPage > 0 ? { page: currentPage } : {};
|
||||
const block: TemplateFieldPlacement =
|
||||
kind === 'image'
|
||||
? {
|
||||
id: blockId(),
|
||||
variable,
|
||||
type: 'image',
|
||||
...page,
|
||||
xPct: 10,
|
||||
yPct: 10,
|
||||
widthPct: 15,
|
||||
@@ -116,6 +136,7 @@ export function useTemplateDraft(templates: LicenseTemplate[]) {
|
||||
variable,
|
||||
text,
|
||||
type: 'text',
|
||||
...page,
|
||||
xPct: 10,
|
||||
yPct: 10,
|
||||
widthPct: 30,
|
||||
@@ -128,7 +149,7 @@ export function useTemplateDraft(templates: LicenseTemplate[]) {
|
||||
setPlacements((prev) => [...prev, block]);
|
||||
setSelectedBlockId(block.id);
|
||||
},
|
||||
[],
|
||||
[currentPage],
|
||||
);
|
||||
|
||||
const updateBlock = useCallback((next: TemplateFieldPlacement) => {
|
||||
@@ -185,6 +206,10 @@ export function useTemplateDraft(templates: LicenseTemplate[]) {
|
||||
setSelectedBlockId,
|
||||
selectedBlock,
|
||||
usesCanvas,
|
||||
currentPage,
|
||||
setCurrentPage,
|
||||
pageCount,
|
||||
addPage,
|
||||
addBlock,
|
||||
updateBlock,
|
||||
deleteBlock,
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Button,
|
||||
Container,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
Stack,
|
||||
Tabs,
|
||||
@@ -23,7 +24,6 @@ import {
|
||||
useArchiveLicenseTemplateMutation,
|
||||
useCreateLicenseTemplateMutation,
|
||||
useDeleteLicenseTemplateMutation,
|
||||
useGetBuiltInTemplateQuery,
|
||||
useGetLicenseTemplatesQuery,
|
||||
useGetLicenseTypesQuery,
|
||||
useGetRanksQuery,
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
usePublishLicenseTemplateMutation,
|
||||
useUpdateLicenseTemplateMutation,
|
||||
} from '@ema-platform/api';
|
||||
import { EmptyState, ErrorState, PageHeader, PdfPreviewModal } from '@ema-platform/ui';
|
||||
import { EmptyState, ErrorState, ModalFooter, PageHeader, PdfPreviewModal } from '@ema-platform/ui';
|
||||
import { usePermissions, LICENSE_PERMISSIONS as PERMISSIONS } from '@ema-platform/auth';
|
||||
import { BlockPropertiesPanel } from '../components/BlockPropertiesPanel';
|
||||
import { DesignerToolbar } from '../components/DesignerToolbar';
|
||||
@@ -62,6 +62,9 @@ export function CertificateDesignerPage() {
|
||||
const { can } = usePermissions();
|
||||
|
||||
const canEdit = can([PERMISSIONS.UPDATE_TEMPLATE]);
|
||||
// Its own permission on the server, so the button follows it rather than
|
||||
// showing to an editor who would only be refused on click.
|
||||
const canCreate = can([PERMISSIONS.CREATE_TEMPLATE]);
|
||||
const canPublish = can([PERMISSIONS.PUBLISH_TEMPLATE]);
|
||||
|
||||
const { data: licenseTypes } = useGetLicenseTypesQuery();
|
||||
@@ -79,8 +82,10 @@ export function CertificateDesignerPage() {
|
||||
// default both come back, so the version list is scoped to whichever the
|
||||
// toolbar has selected.
|
||||
const templates = allTemplates.filter((tpl) => (tpl.rankId ?? null) === rankId);
|
||||
const { data: variables = [] } = useGetTemplateVariablesQuery();
|
||||
const { data: builtIn } = useGetBuiltInTemplateQuery();
|
||||
// Per type, so the palette also lists this form's own answers.
|
||||
const { data: variables = [] } = useGetTemplateVariablesQuery(
|
||||
typeId ? { licenseTypeId: typeId } : undefined,
|
||||
);
|
||||
|
||||
const [createTemplate, { isLoading: creating }] = useCreateLicenseTemplateMutation();
|
||||
const [updateTemplate, { isLoading: saving }] = useUpdateLicenseTemplateMutation();
|
||||
@@ -94,25 +99,23 @@ export function CertificateDesignerPage() {
|
||||
|
||||
const [newOpen, setNewOpen] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [mode, setMode] = useState<'canvas' | 'source'>('canvas');
|
||||
|
||||
const selectedType = licenseTypes?.items?.find((type) => type.id === typeId);
|
||||
|
||||
// A rank ladder only exists for CoC/CoP — every other licence type designs
|
||||
// one certificate for everyone who holds it. Keyed on `key`, not
|
||||
// `certificateCategory`: that STCW-mapping column is unset on the seeded
|
||||
// CoC/CoP rows (it's authored later, per StcwMappingPanel), while `key` is
|
||||
// the stable identity CertificateEligibilityService itself branches on.
|
||||
// CoC/CoP are each a single LicenseType spanning every department's ladder
|
||||
// (the applicant's own department, not the type, decides which ladder they
|
||||
// climb), so the picker offers every rank in the ladder across all
|
||||
// departments rather than one department's.
|
||||
// one certificate for everyone who holds it. Keyed on the configured
|
||||
// `certificateCategory`, the same column the server's rank resolution reads,
|
||||
// so a type an administrator marks as a CoC/CoP on the Behaviour tab gets a
|
||||
// rank picker without a code change. CoC/CoP are each a single LicenseType
|
||||
// spanning every department's ladder (the applicant's own department, not
|
||||
// the type, decides which ladder they climb), so the picker offers every
|
||||
// rank across all departments rather than one department's.
|
||||
const rankCategory: 'COC' | 'COP' | null =
|
||||
selectedType?.key === 'CERTIFICATE_OF_COMPETENCY'
|
||||
? 'COC'
|
||||
: selectedType?.key === 'CERTIFICATE_OF_PROFICIENCY'
|
||||
? 'COP'
|
||||
: null;
|
||||
selectedType?.certificateCategory === 'COC' || selectedType?.certificateCategory === 'COP'
|
||||
? selectedType.certificateCategory
|
||||
: null;
|
||||
const isRankScoped = rankCategory !== null;
|
||||
const { data: allRanks } = useGetRanksQuery(undefined, { skip: !isRankScoped });
|
||||
const ranks = (allRanks?.items ?? [])
|
||||
@@ -164,7 +167,7 @@ export function CertificateDesignerPage() {
|
||||
}}
|
||||
validityMonths={selectedType?.validityMonths ?? 12}
|
||||
validityDays={selectedType?.validityDays ?? null}
|
||||
canEdit={canEdit}
|
||||
canEdit={canCreate}
|
||||
onNewVersion={startNewVersion}
|
||||
/>
|
||||
|
||||
@@ -183,7 +186,7 @@ export function CertificateDesignerPage() {
|
||||
'Certificates currently use the built-in layout. Create a version to take control of it.',
|
||||
)}
|
||||
action={
|
||||
canEdit
|
||||
canCreate
|
||||
? { label: t('designer.newVersion', 'New version'), onClick: startNewVersion }
|
||||
: undefined
|
||||
}
|
||||
@@ -228,10 +231,10 @@ export function CertificateDesignerPage() {
|
||||
<Text size="sm">
|
||||
{t(
|
||||
'designer.liveDesignBody',
|
||||
'Certificates have been issued from this version, so it cannot be changed. Create a new version to edit — it starts as a copy of this one, and only replaces it when you publish.',
|
||||
'Certificates are issued from this version, so it cannot be changed. Create a new version to edit — it starts as a copy of this one, and only replaces it when you publish.',
|
||||
)}
|
||||
</Text>
|
||||
{canEdit && (
|
||||
{canCreate && (
|
||||
<Button
|
||||
size="xs"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
@@ -274,6 +277,10 @@ export function CertificateDesignerPage() {
|
||||
onSelect={draft.setSelectedBlockId}
|
||||
onChange={draft.setPlacements}
|
||||
disabled={editingLocked}
|
||||
page={draft.currentPage}
|
||||
pageCount={draft.pageCount}
|
||||
onPageChange={draft.setCurrentPage}
|
||||
onAddPage={draft.addPage}
|
||||
/>
|
||||
<BlockPropertiesPanel
|
||||
block={draft.selectedBlock}
|
||||
@@ -356,8 +363,10 @@ export function CertificateDesignerPage() {
|
||||
? { width: draft.pageWidth, height: draft.pageHeight }
|
||||
: undefined,
|
||||
),
|
||||
backgroundUrl: draft.backgroundUrl || undefined,
|
||||
logoUrl: draft.logoUrl || undefined,
|
||||
// Null, not undefined: undefined means "leave as stored",
|
||||
// which is what made Remove look like it worked until reload.
|
||||
backgroundUrl: draft.backgroundUrl.trim() || null,
|
||||
logoUrl: draft.logoUrl.trim() || null,
|
||||
logoPlacement: draft.logoPlacement,
|
||||
fieldPlacements: draft.placements,
|
||||
}).unwrap(),
|
||||
@@ -376,12 +385,7 @@ export function CertificateDesignerPage() {
|
||||
t('designer.archived', 'Design withdrawn'),
|
||||
)
|
||||
}
|
||||
onDelete={() =>
|
||||
run(
|
||||
() => deleteTemplate(draft.selected!.id).unwrap(),
|
||||
t('designer.deleted', 'Draft deleted'),
|
||||
)
|
||||
}
|
||||
onDelete={() => setDeleteOpen(true)}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
@@ -404,11 +408,15 @@ export function CertificateDesignerPage() {
|
||||
onClose={() => setNewOpen(false)}
|
||||
onCreate={() =>
|
||||
run(async () => {
|
||||
// No source is sent on purpose: the server seeds the draft from
|
||||
// the live design for this rank, then the type's default, then
|
||||
// the built-in layout — and it copies that design's blocks and
|
||||
// artwork along with the HTML, which sending a source here would
|
||||
// have split apart.
|
||||
const created = await createTemplate({
|
||||
licenseTypeId: typeId as string,
|
||||
rankId,
|
||||
name: newName.trim(),
|
||||
hbsSource: templates.length ? undefined : builtIn?.hbsSource,
|
||||
}).unwrap();
|
||||
draft.setSelectedId(created.id);
|
||||
setNewOpen(false);
|
||||
@@ -416,6 +424,39 @@ export function CertificateDesignerPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
opened={deleteOpen}
|
||||
onClose={() => setDeleteOpen(false)}
|
||||
title={t('designer.delete', 'Delete draft')}
|
||||
size="sm"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm">
|
||||
{t('designer.deleteConfirm', 'Delete "{{name}}" (v{{version}})? This cannot be undone.', {
|
||||
name: draft.selected?.name ?? '',
|
||||
version: draft.selected?.version ?? '',
|
||||
})}
|
||||
</Text>
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={() => setDeleteOpen(false)}>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
onClick={async () => {
|
||||
setDeleteOpen(false);
|
||||
await run(
|
||||
() => deleteTemplate(draft.selected!.id).unwrap(),
|
||||
t('designer.deleted', 'Draft deleted'),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{t('designer.delete', 'Delete draft')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<PdfPreviewModal
|
||||
opened={Boolean(previewUrl)}
|
||||
onClose={closePreview}
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
type CertificateCategory,
|
||||
type CompletionEffect,
|
||||
type LicenseType,
|
||||
type ServiceKind,
|
||||
type WorkflowProfile,
|
||||
} from '@ema-platform/api';
|
||||
import { useRequirementActions } from '../hooks/useRequirementActions';
|
||||
@@ -29,7 +28,6 @@ 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;
|
||||
@@ -57,7 +55,6 @@ 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,
|
||||
@@ -72,9 +69,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 +115,62 @@ export function BehaviorTab({ licenseType }: { licenseType: LicenseType }) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [licenseType.id]);
|
||||
|
||||
function set<K extends keyof Draft>(key: K, value: Draft[K]) {
|
||||
setDraft((current) => ({ ...current, [key]: value }));
|
||||
function patch(values: Partial<Draft>) {
|
||||
setDraft((current) => ({ ...current, ...values }));
|
||||
setDirty(true);
|
||||
}
|
||||
|
||||
function set<K extends keyof Draft>(key: K, value: Draft[K]) {
|
||||
patch({ [key]: value } as Partial<Draft>);
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
// A type switched from "does not expire" straight to a term in days still
|
||||
// carries `validityMonths: 0`, which the server's 6-month floor refuses.
|
||||
// Days win over months at issuance, so the zero is simply left alone.
|
||||
const term = expires
|
||||
? {
|
||||
validityDays,
|
||||
...(validityMonths > 0 ? { validityMonths } : {}),
|
||||
}
|
||||
: {};
|
||||
|
||||
const ok = await run(
|
||||
() => save({ id: licenseType.id, ...draft }).unwrap(),
|
||||
() =>
|
||||
save({
|
||||
id: licenseType.id,
|
||||
...rest,
|
||||
...term,
|
||||
...(expires && draft.renewalEnabled
|
||||
? { renewalWindowDays, expiryReminderDays }
|
||||
: {}),
|
||||
}).unwrap(),
|
||||
t('certReq.behavior.saved', 'Configuration saved.'),
|
||||
);
|
||||
if (ok) setDirty(false);
|
||||
@@ -199,21 +248,10 @@ export function BehaviorTab({ licenseType }: { licenseType: LicenseType }) {
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label={t('certReq.behavior.serviceKind', 'Service kind')}
|
||||
description={t(
|
||||
'certReq.behavior.serviceKindHint',
|
||||
'Catalogue classification only — no workflow depends on it.',
|
||||
)}
|
||||
data={[
|
||||
{ value: 'LICENSE', label: t('certReq.behavior.license', 'Licence') },
|
||||
{ value: 'REGISTRATION', label: t('certReq.behavior.registrationKind', 'Registration') },
|
||||
]}
|
||||
value={draft.serviceKind}
|
||||
onChange={(v) => v && set('serviceKind', v as ServiceKind)}
|
||||
allowDeselect={false}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
{/* `serviceKind` is deliberately not offered here: nothing in the
|
||||
backend or either portal reads it, so a control for it would be
|
||||
a setting that changes nothing. Bring it back once something
|
||||
branches on it. */}
|
||||
</Section>
|
||||
|
||||
<Section title={t('certReq.behavior.eligibility', 'Eligibility gates')}>
|
||||
@@ -289,94 +327,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`. */}
|
||||
<Group align="flex-end" gap="sm" wrap="nowrap">
|
||||
<NumberInput
|
||||
label={t('certReq.behavior.validity', 'Valid for')}
|
||||
description={t(
|
||||
'certReq.behavior.validityHint',
|
||||
'Applied when a licence is issued. Licences already issued keep the expiry date they were given.',
|
||||
)}
|
||||
value={draft.validityDays ?? draft.validityMonths}
|
||||
onChange={(v) => {
|
||||
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 && (
|
||||
<NumberInput
|
||||
label={t('certReq.behavior.validity', 'Valid for')}
|
||||
description={t(
|
||||
'certReq.behavior.validityHint',
|
||||
'Applied when a licence is issued. Licences already issued keep the expiry date they were given.',
|
||||
)}
|
||||
value={draft.validityDays ?? draft.validityMonths}
|
||||
onChange={(v) => {
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
<Select
|
||||
label={
|
||||
expires
|
||||
? undefined
|
||||
: t('certReq.behavior.validityUnit', 'Validity unit')
|
||||
}
|
||||
aria-label={t('certReq.behavior.validityUnit', 'Validity unit')}
|
||||
data={[
|
||||
{ value: 'MONTHS', label: t('certReq.behavior.unitMonths', 'Months') },
|
||||
{ value: 'DAYS', label: t('certReq.behavior.unitDays', 'Days') },
|
||||
...(noExpiryAvailable
|
||||
? [
|
||||
{
|
||||
value: 'NONE',
|
||||
label: t('certReq.behavior.unitNone', 'Does not expire'),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
value={draft.validityDays !== null ? 'DAYS' : 'MONTHS'}
|
||||
value={
|
||||
draft.validityDays !== null
|
||||
? 'DAYS'
|
||||
: draft.validityMonths > 0
|
||||
? 'MONTHS'
|
||||
: 'NONE'
|
||||
}
|
||||
onChange={(unit) => {
|
||||
// Switching unit is a change of policy, not a conversion: 12
|
||||
// calendar months is not 365 days, so carry no arithmetic across
|
||||
// and let the administrator state the new term outright.
|
||||
if (unit === 'DAYS') set('validityDays', draft.validityDays ?? 90);
|
||||
else set('validityDays', null);
|
||||
else if (unit === 'MONTHS')
|
||||
patch({
|
||||
validityDays: null,
|
||||
validityMonths:
|
||||
draft.validityMonths >= 6 ? draft.validityMonths : 12,
|
||||
});
|
||||
// No expiry means no renewal policy: clear it here rather than
|
||||
// save renewal settings that could never fire.
|
||||
else
|
||||
patch({
|
||||
validityDays: null,
|
||||
validityMonths: 0,
|
||||
renewalEnabled: false,
|
||||
});
|
||||
}}
|
||||
allowDeselect={false}
|
||||
disabled={!canEdit}
|
||||
w={130}
|
||||
w={expires ? 130 : 220}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Switch
|
||||
checked={draft.renewalEnabled}
|
||||
onChange={(e) => 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 && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{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.',
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* 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 && (
|
||||
<>
|
||||
<NumberInput
|
||||
label={t('certReq.behavior.renewalWindow', 'Renewal opens (days before expiry)')}
|
||||
value={draft.renewalWindowDays}
|
||||
onChange={(v) =>
|
||||
set('renewalWindowDays', typeof v === 'number' ? v : draft.renewalWindowDays)
|
||||
}
|
||||
min={1}
|
||||
max={365}
|
||||
allowNegative={false}
|
||||
<Switch
|
||||
checked={draft.renewalEnabled}
|
||||
onChange={(e) => 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}
|
||||
/>
|
||||
|
||||
<MultiSelect
|
||||
label={t('certReq.behavior.reminders', 'Expiry reminders (days before)')}
|
||||
description={t(
|
||||
'certReq.behavior.remindersHint',
|
||||
'The holder is reminded at each of these offsets.',
|
||||
)}
|
||||
data={REMINDER_OFFSETS}
|
||||
value={draft.expiryReminderDays.map(String)}
|
||||
onChange={(values) =>
|
||||
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 && (
|
||||
<>
|
||||
<NumberInput
|
||||
label={t('certReq.behavior.renewalWindow', 'Renewal opens (days before expiry)')}
|
||||
value={draft.renewalWindowDays}
|
||||
onChange={(v) =>
|
||||
set('renewalWindowDays', typeof v === 'number' ? v : draft.renewalWindowDays)
|
||||
}
|
||||
min={1}
|
||||
max={365}
|
||||
allowNegative={false}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
|
||||
<MultiSelect
|
||||
label={t('certReq.behavior.reminders', 'Expiry reminders (days before)')}
|
||||
description={t(
|
||||
'certReq.behavior.remindersHint',
|
||||
'The holder is reminded at each of these offsets.',
|
||||
)}
|
||||
data={REMINDER_OFFSETS}
|
||||
value={draft.expiryReminderDays.map(String)}
|
||||
onChange={(values) =>
|
||||
set(
|
||||
'expiryReminderDays',
|
||||
values.map(Number).sort((a, b) => b - a),
|
||||
)
|
||||
}
|
||||
disabled={!canEdit}
|
||||
clearable
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
@@ -322,6 +322,7 @@ export function DocumentRequirementEditorDrawer({
|
||||
data={[
|
||||
{ value: 'NEW', label: t('certReq.doc.kindNew', 'New application') },
|
||||
{ value: 'RENEWAL', label: t('certReq.doc.kindRenewal', 'Renewal') },
|
||||
{ value: 'REISSUE', label: t('certReq.doc.kindReissue', 'Damaged / reissue') },
|
||||
]}
|
||||
value={draft.applicationKind}
|
||||
onChange={(v) => v && setDraft((d) => ({ ...d, applicationKind: v as ApplicationKind }))}
|
||||
|
||||
@@ -20,7 +20,14 @@ import { useRequirementActions } from '../hooks/useRequirementActions';
|
||||
import { describeCondition } from './ConditionBuilder';
|
||||
import { DocumentRequirementEditorDrawer } from './DocumentRequirementEditorDrawer';
|
||||
|
||||
const KINDS: ApplicationKind[] = ['NEW', 'RENEWAL'];
|
||||
/** Every kind the server validates a document set for, so each can be configured. */
|
||||
const KINDS: ApplicationKind[] = ['NEW', 'RENEWAL', 'REISSUE'];
|
||||
|
||||
const KIND_LABEL: Record<ApplicationKind, [string, string]> = {
|
||||
NEW: ['certReq.doc.kindNew', 'New application'],
|
||||
RENEWAL: ['certReq.doc.kindRenewal', 'Renewal'],
|
||||
REISSUE: ['certReq.doc.kindReissue', 'Damaged / reissue'],
|
||||
};
|
||||
|
||||
const MODE_COLOR: Record<DocumentRequirement['mode'], string> = {
|
||||
ALWAYS: 'blue',
|
||||
@@ -112,9 +119,7 @@ export function DocumentRequirementsTab({ licenseType }: { licenseType: LicenseT
|
||||
return (
|
||||
<Card key={kind} withBorder radius="md" p="md">
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Title order={5}>
|
||||
{kind === 'NEW' ? t('certReq.doc.kindNew', 'New application') : t('certReq.doc.kindRenewal', 'Renewal')}
|
||||
</Title>
|
||||
<Title order={5}>{t(...KIND_LABEL[kind])}</Title>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
|
||||
@@ -88,8 +88,6 @@ export const am: Translations = {
|
||||
btcQueue: "የBTC ወረፋ",
|
||||
cocQueue: "የCoC ወረፋ",
|
||||
copQueue: "የCoP ወረፋ",
|
||||
endorsementCocQueue: "የCoC እውቅና ወረፋ",
|
||||
endorsementGocQueue: "የGOC እውቅና ወረፋ",
|
||||
endorsementQueue: "የማስተያየት ወረፋ",
|
||||
vesselRegistrations: "የመርከብ ምዝገባ",
|
||||
// vesselRegistrationReport: 'የምዝገባ ሪፖርት',
|
||||
@@ -1365,6 +1363,7 @@ export const am: Translations = {
|
||||
validityMonths_other: "{{count}} ወራት",
|
||||
validityDays_one: "{{count}} ቀን",
|
||||
validityDays_other: "{{count}} ቀናት",
|
||||
validityNone: "ጊዜ አልተቀመጠም — ለ12 ወራት ይሰጣል",
|
||||
validityEditedOn: "— በምስክር ወረቀት መስፈርቶች → ባህሪ ውስጥ ይዘጋጃል",
|
||||
newVersion: "አዲስ ስሪት",
|
||||
versions: "ስሪቶች",
|
||||
@@ -1382,7 +1381,12 @@ export const am: Translations = {
|
||||
published: "ንድፉ ታትሟል",
|
||||
publishHint: "ይህንን ቀጥታ የምስክር ወረቀት ንድፍ ያደርገዋል",
|
||||
publishedLocked:
|
||||
"ይህ ስሪት ቀጥታ ላይ ስለሆነ ማስተካከል አይቻልም — ከእሱ የምስክር ወረቀቶች ተሰጥተዋል። ለውጥ ለማድረግ አዲስ ስሪት ይፍጠሩ።",
|
||||
"ይህ ስሪት ቀጥታ ላይ ስለሆነ ማስተካከል አይቻልም — የምስክር ወረቀቶች ከእሱ ይሰጣሉ። ለውጥ ለማድረግ አዲስ ስሪት ይፍጠሩ።",
|
||||
deleteConfirm: '"{{name}}" (v{{version}}) ይሰረዝ? ይህ መቀልበስ አይቻልም።',
|
||||
pageTab: "ገጽ {{n}}",
|
||||
addPage: "ገጽ ጨምር",
|
||||
blockPage: "ገጽ",
|
||||
blockPageHint: "ይህ ብሎክ በየትኛው የሰነዱ ገጽ ላይ እንደሚታተም",
|
||||
archive: "አንሳ",
|
||||
archived: "ንድፉ ተነስቷል",
|
||||
delete: "ረቂቅ ሰርዝ",
|
||||
@@ -1451,8 +1455,11 @@ export const am: Translations = {
|
||||
validityUnit: "የሚቆይበት መለኪያ",
|
||||
unitMonths: "ወራት",
|
||||
unitDays: "ቀናት",
|
||||
unitNone: "ጊዜው አያልፍም",
|
||||
validityHint:
|
||||
"ፈቃድ ሲሰጥ ተግባራዊ ይሆናል። ቀደም ብለው የተሰጡ ፈቃዶች የተሰጣቸውን የማብቂያ ቀን ይይዛሉ።",
|
||||
noExpiryHint:
|
||||
"ይህ ዓይነት የሚሰጠው ሰነድ ጊዜው አያልፍም — የባለቤትነት ዝውውር ወይም አንድ ጊዜ ብቻ የሚሰጥ መዝገብ። የሚቀመጥ የዕድሳት መመሪያ የለም።",
|
||||
requiresSeafarer: "የጸና የመርከበኛ ምዝገባ ያስፈልገዋል",
|
||||
requiresSeafarerHint:
|
||||
"ይህንን ዓይነት እንደ የመርከበኛ የምስክር ወረቀት ያመለክታል፤ በመርከበኛው ፖርታል ላይ እንዲታይ የሚያደርገው ይኸው ነው።",
|
||||
@@ -1585,6 +1592,7 @@ export const am: Translations = {
|
||||
applicationKind: "የማመልከቻ ዓይነት",
|
||||
kindNew: "አዲስ ማመልከቻ",
|
||||
kindRenewal: "ዕድሳት",
|
||||
kindReissue: "የተበላሸ / ምትክ",
|
||||
kindLocked: "ከተፈጠረ በኋላ የማመልከቻ ዓይነት መቀየር አይቻልም",
|
||||
mode: "ዘዴ",
|
||||
modeAlways: "ሁልጊዜ ያስፈልጋል",
|
||||
|
||||
@@ -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',
|
||||
@@ -1372,6 +1370,7 @@ export const en = {
|
||||
validityMonths_other: '{{count}} months',
|
||||
validityDays_one: '{{count}} day',
|
||||
validityDays_other: '{{count}} days',
|
||||
validityNone: 'No term set — issues for 12 months',
|
||||
validityEditedOn: '— set on Certificate Requirements → Behaviour',
|
||||
newVersion: 'New version',
|
||||
versions: 'Versions',
|
||||
@@ -1388,7 +1387,12 @@ export const en = {
|
||||
publish: 'Publish',
|
||||
published: 'Design published',
|
||||
publishHint: 'Makes this the live certificate design',
|
||||
publishedLocked: 'This version is live and cannot be edited — certificates have been issued from it. Create a new version to make changes.',
|
||||
publishedLocked: 'This version is live and cannot be edited — certificates are issued from it. Create a new version to make changes.',
|
||||
deleteConfirm: 'Delete "{{name}}" (v{{version}})? This cannot be undone.',
|
||||
pageTab: 'Page {{n}}',
|
||||
addPage: 'Add page',
|
||||
blockPage: 'Page',
|
||||
blockPageHint: 'Which sheet of the document this block prints on',
|
||||
archive: 'Withdraw',
|
||||
archived: 'Design withdrawn',
|
||||
delete: 'Delete draft',
|
||||
@@ -1457,8 +1461,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.',
|
||||
@@ -1591,6 +1598,7 @@ export const en = {
|
||||
applicationKind: 'Application kind',
|
||||
kindNew: 'New application',
|
||||
kindRenewal: 'Renewal',
|
||||
kindReissue: 'Damaged / reissue',
|
||||
kindLocked: 'Application kind cannot change once created',
|
||||
mode: 'Mode',
|
||||
modeAlways: 'Always required',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -114,7 +114,7 @@ const STATUS_COLOR: Record<string, string> = {
|
||||
* keys because this page states its statuses the same way.
|
||||
*/
|
||||
const EXAM_STAGE_LABELS: Record<string, string> = {
|
||||
ELIGIBLE_TO_REGISTER: 'Eligible — Register for a Sitting',
|
||||
EXAM_PAID: 'Exam Paid',
|
||||
REGISTERED: 'Exam Scheduled',
|
||||
ATTENDANCE_CONFIRMED: 'Exam Attendance Confirmed',
|
||||
SITTING: 'Exam In Progress',
|
||||
|
||||
@@ -25,8 +25,6 @@ import {
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FilePreviewModal } from '@ema-platform/ui';
|
||||
|
||||
const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
interface Props {
|
||||
requirements: DocumentRequirement[];
|
||||
attachments: Attachment[];
|
||||
@@ -87,7 +85,11 @@ export function DocumentSlots({
|
||||
|
||||
async function handle(documentKey: string, file: File | null) {
|
||||
if (!file) return;
|
||||
if (file.size > MAX_FILE_SIZE_BYTES) {
|
||||
// The slot's own limit, as configured in the backoffice — the server
|
||||
// refuses anything larger, so the check here only saves the round trip.
|
||||
const maxSizeMb =
|
||||
requirements.find((r) => r.key === documentKey)?.maxSizeMb ?? 5;
|
||||
if (file.size > maxSizeMb * 1024 * 1024) {
|
||||
setError(
|
||||
t('licensing.msg.fileTooLarge', {
|
||||
size: `${(file.size / 1024 / 1024).toFixed(1)}MB`,
|
||||
|
||||
@@ -322,8 +322,16 @@ function LicenseTypeCard({
|
||||
</Tooltip>
|
||||
)}
|
||||
{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".
|
||||
<Badge size="sm" variant="light" color="teal">
|
||||
{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')}
|
||||
</Badge>
|
||||
) : (
|
||||
<Tooltip label={t('licensing.catalogue.evaluationTooltip')}>
|
||||
|
||||
@@ -29,8 +29,8 @@ describe('examStageFor', () => {
|
||||
expect(examStageFor({ status: 'CERTIFICATE_ISSUED' })).toBeNull();
|
||||
});
|
||||
|
||||
it('reads a paid application as eligible to register, not as waiting', () => {
|
||||
expect(examStageFor({ status: 'EXAM_PAID' })).toBe('ELIGIBLE_TO_REGISTER');
|
||||
it('reads a paid application as Exam Paid, not as awaiting a date', () => {
|
||||
expect(examStageFor({ status: 'EXAM_PAID' })).toBe('EXAM_PAID');
|
||||
});
|
||||
|
||||
it('reports a registration whose attendance has not been taken', () => {
|
||||
|
||||
@@ -16,7 +16,7 @@ import type { MyRegistration } from '../exams/pages/ExamsPage';
|
||||
* already recorded — this only names what the records add up to.
|
||||
*/
|
||||
export type ExamStage =
|
||||
| 'ELIGIBLE_TO_REGISTER'
|
||||
| 'EXAM_PAID'
|
||||
| 'REGISTERED'
|
||||
| 'ATTENDANCE_CONFIRMED'
|
||||
| 'SITTING'
|
||||
@@ -41,9 +41,11 @@ export function examStageFor(
|
||||
if (app.status === 'EXAM_PASSED') return 'PASSED';
|
||||
if (app.status === 'EXAM_FAILED') return 'FAILED';
|
||||
|
||||
// EXAM_PAID is "prerequisites done, sitting not yet chosen": the fee has
|
||||
// cleared and nothing is left but for the candidate to pick a session.
|
||||
if (app.status === 'EXAM_PAID') return 'ELIGIBLE_TO_REGISTER';
|
||||
// The fee has cleared and nothing is left but for the candidate to pick a
|
||||
// session. Named after the status rather than after what the candidate
|
||||
// should do next: "Eligible to Register" read as a status of its own and
|
||||
// said less than the plain one.
|
||||
if (app.status === 'EXAM_PAID') return 'EXAM_PAID';
|
||||
|
||||
if (app.status !== 'EXAM_SCHEDULED') return null;
|
||||
|
||||
|
||||
@@ -118,8 +118,20 @@ export function LicenseApplicationPage() {
|
||||
const localized = useLocalized();
|
||||
const accountUser = useAppSelector((state) => state.auth.user);
|
||||
|
||||
const [appId, setAppId] = useState<string | undefined>(applicationId);
|
||||
// Fetched before the requirements because the application's kind decides
|
||||
// which document set is asked for: a renewal started from a licence card
|
||||
// must be shown the RENEWAL slots, not the NEW ones — the same set the
|
||||
// server validates against at submission.
|
||||
const { data: detail, refetch } = useGetApplicationQuery(appId as string, {
|
||||
skip: !appId,
|
||||
});
|
||||
|
||||
const { data: config, isLoading: loadingConfig } =
|
||||
useGetLicenseTypeRequirementsQuery({ idOrKey: typeCode });
|
||||
useGetLicenseTypeRequirementsQuery({
|
||||
idOrKey: typeCode,
|
||||
kind: detail?.application?.kind ?? "NEW",
|
||||
});
|
||||
const { profile } = useCurrentProfile();
|
||||
const { can: hasPermission, known: permissionsKnown } = usePermissions();
|
||||
// Only the vessel-select field (ConfigDrivenSection) reads this — fetched
|
||||
@@ -130,7 +142,6 @@ export function LicenseApplicationPage() {
|
||||
skip: !permissionsKnown || !hasPermission([PORTAL_PERMISSIONS.VIEW_OWN_VESSELS]),
|
||||
});
|
||||
const [createApplication] = useCreateApplicationMutation();
|
||||
const [appId, setAppId] = useState<string | undefined>(applicationId);
|
||||
|
||||
// Create (or resume) the draft up front, so uploads have a real owner to
|
||||
// attach to and nothing is lost if the browser is closed mid-wizard.
|
||||
@@ -151,9 +162,6 @@ export function LicenseApplicationPage() {
|
||||
);
|
||||
}, [appId, config, createApplication, typeCode]);
|
||||
|
||||
const { data: detail, refetch } = useGetApplicationQuery(appId as string, {
|
||||
skip: !appId,
|
||||
});
|
||||
const { data: attachments = [], refetch: refetchAttachments } =
|
||||
useGetAttachmentsQuery(
|
||||
{ ownerType: "APPLICATION", ownerId: appId as string },
|
||||
|
||||
@@ -34,9 +34,9 @@ export function PaymentCheckPage() {
|
||||
const { t } = useTranslation();
|
||||
const [params] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const applicationId = params.get('applicationId') ?? '';
|
||||
const applicationId = (params.get('applicationId') ?? '').split('?')[0];
|
||||
// Seaman Book / BTC fees come back with `documentId` instead.
|
||||
const documentId = params.get('documentId') ?? '';
|
||||
const documentId = (params.get('documentId') ?? '').split('?')[0];
|
||||
const [attempts, setAttempts] = useState(0);
|
||||
|
||||
const applicationPayment = useGetApplicationPaymentQuery(applicationId, {
|
||||
|
||||
@@ -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 (
|
||||
<Col span={p.span}>
|
||||
<AmharicDatePicker
|
||||
@@ -70,6 +70,8 @@ export function DateField(p: FieldProps) {
|
||||
disabled={p.disabled}
|
||||
required={p.required}
|
||||
dateFormat="date"
|
||||
minDate={p.minDate}
|
||||
maxDate={p.maxDate}
|
||||
value={(p.form[p.name] as string) ?? ''}
|
||||
onChange={(v) => p.set(p.name, v)}
|
||||
/>
|
||||
|
||||
@@ -220,6 +220,10 @@ export function EmergencyContactStep(p: StepProps) {
|
||||
name="medicalIssueDate"
|
||||
label="Issue Date"
|
||||
required
|
||||
// Bounded here rather than only at submit: the calendar is the one
|
||||
// place the applicant can see why a day is refused, and the server's
|
||||
// rejection otherwise only surfaces five steps later.
|
||||
maxDate={new Date()}
|
||||
description="Cannot be a future date. Validity is calculated from this: two years, or one year if you are under 18."
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
@@ -80,6 +80,11 @@ function answersOf(registration: SeafarerRegistration): SaveSeafarerRegistration
|
||||
return Object.fromEntries(ANSWER_KEYS.map((k) => [k, registration[k]])) as SaveSeafarerRegistration;
|
||||
}
|
||||
|
||||
/** Today as `yyyy-mm-dd` in the browser's own zone — en-CA is that format. */
|
||||
function todayDate(): string {
|
||||
return new Date().toLocaleDateString('en-CA');
|
||||
}
|
||||
|
||||
function blank(value: unknown): boolean {
|
||||
return value === null || value === undefined || value === '' || value === false;
|
||||
}
|
||||
@@ -258,6 +263,10 @@ export function SeafarerRegistrationPage() {
|
||||
|
||||
function set(key: AnswerKey, value: unknown) {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
// The submit refusal names what was wrong at the time it was refused.
|
||||
// Leaving it on screen while the applicant corrects it reads as the
|
||||
// correction having been ignored.
|
||||
if (issues.length) setIssues([]);
|
||||
setErrors((prev) => {
|
||||
if (!prev[key]) return prev;
|
||||
const next = { ...prev };
|
||||
@@ -280,6 +289,9 @@ export function SeafarerRegistrationPage() {
|
||||
found.weightKg = `Enter a weight between ${weightKg.min} and ${weightKg.max} kg.`;
|
||||
}
|
||||
}
|
||||
if (index === 2 && (form.medicalIssueDate ?? '').slice(0, 10) > todayDate()) {
|
||||
found.medicalIssueDate = 'The issue date cannot be in the future.';
|
||||
}
|
||||
setErrors(found);
|
||||
const missingKeys = Object.keys(found) as AnswerKey[];
|
||||
if (missingKeys.length) {
|
||||
@@ -323,19 +335,23 @@ export function SeafarerRegistrationPage() {
|
||||
}
|
||||
|
||||
async function goToStep(target: number) {
|
||||
if (target <= active) {
|
||||
setActive(target);
|
||||
return;
|
||||
}
|
||||
// Going forward validates every step passed over, so a jump cannot skip a
|
||||
// required field; the walk stops on the first step that fails.
|
||||
for (let step = active; step < target; step++) {
|
||||
if (!readOnly && !validateStep(step)) {
|
||||
setActive(step);
|
||||
return;
|
||||
// Saved before anything can turn the navigation around. `form` is the only
|
||||
// copy of what was typed, so validating first — as this used to — threw the
|
||||
// edit away on every blocked step and every step back: an applicant fixing
|
||||
// a field the submit check rejected watched the correction vanish. A draft
|
||||
// takes any subset of the answers, so persisting an incomplete one is safe.
|
||||
const saved = await saveAnswers();
|
||||
if (target > active) {
|
||||
if (!saved) return;
|
||||
// Going forward validates every step passed over, so a jump cannot skip a
|
||||
// required field; the walk stops on the first step that fails.
|
||||
for (let step = active; step < target; step++) {
|
||||
if (!readOnly && !validateStep(step)) {
|
||||
setActive(step);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!(await saveAnswers())) return;
|
||||
setErrors({});
|
||||
setActive(target);
|
||||
}
|
||||
@@ -343,8 +359,8 @@ export function SeafarerRegistrationPage() {
|
||||
async function handleSubmit() {
|
||||
if (!registration) return;
|
||||
setIssues([]);
|
||||
if (!readOnly && !validateStep(4)) return;
|
||||
if (!(await saveAnswers())) return;
|
||||
if (!readOnly && !validateStep(4)) return;
|
||||
try {
|
||||
await submit(registration.id).unwrap();
|
||||
notifications.show({
|
||||
@@ -510,7 +526,7 @@ export function SeafarerRegistrationPage() {
|
||||
)}
|
||||
|
||||
<Group justify="space-between" mt="xl">
|
||||
<Button variant="default" onClick={() => setActive((s) => Math.max(0, s - 1))} disabled={active === 0}>
|
||||
<Button variant="default" onClick={() => goToStep(Math.max(0, active - 1))} disabled={active === 0}>
|
||||
Back
|
||||
</Button>
|
||||
{active < STEPS.length - 1 ? (
|
||||
|
||||
@@ -244,7 +244,7 @@ export const am: Translations = {
|
||||
empty: 'እስካሁን ምንም ፍቃድ አልተሰጥዎትም። ማመልከቻ ከተፈቀደና ከተከፈለ በኋላ እዚህ ይታያል።',
|
||||
},
|
||||
examStage: {
|
||||
ELIGIBLE_TO_REGISTER: "ብቁ ነዎት — ለፈተና ይመዝገቡ",
|
||||
EXAM_PAID: "የፈተና ክፍያ ተከፍሏል",
|
||||
REGISTERED: "የፈተና ቀን፦ {{date}}",
|
||||
ATTENDANCE_CONFIRMED: "መገኘት ተረጋግጧል",
|
||||
SITTING: "ፈተና በመካሄድ ላይ",
|
||||
@@ -893,6 +893,9 @@ export const am: Translations = {
|
||||
capitalTooltip: 'በባንክ ደብዳቤ መረጋገጥ ያለበት ዝቅተኛ ካፒታል',
|
||||
capitalBadge: 'ካፒታል {{amount}}',
|
||||
validityBadge: '{{months}} ወራት',
|
||||
validityDaysBadge_one: '{{count}} ቀን',
|
||||
validityDaysBadge_other: '{{count}} ቀናት',
|
||||
noExpiryBadge: 'ጊዜው አያልፍም',
|
||||
evaluationTooltip: 'በምስክር ወረቀት ፈንታ በባለሥልጣኑ ውሳኔ የሚጠናቀቅ',
|
||||
evaluationOnly: 'ግምገማ ብቻ',
|
||||
startApplication: 'ማመልከቻ ጀምር',
|
||||
|
||||
@@ -249,7 +249,7 @@ export const en = {
|
||||
* from the application status alone — see exam-stage.ts.
|
||||
*/
|
||||
examStage: {
|
||||
ELIGIBLE_TO_REGISTER: 'Eligible — register for a sitting',
|
||||
EXAM_PAID: 'Exam Paid',
|
||||
REGISTERED: 'Exam scheduled: {{date}}',
|
||||
ATTENDANCE_CONFIRMED: 'Attendance confirmed',
|
||||
SITTING: 'Exam in progress',
|
||||
@@ -899,6 +899,9 @@ export const en = {
|
||||
capitalTooltip: 'Minimum capital that must be evidenced by a bank letter',
|
||||
capitalBadge: 'Capital {{amount}}',
|
||||
validityBadge: '{{months}} months',
|
||||
validityDaysBadge_one: '{{count}} day',
|
||||
validityDaysBadge_other: '{{count}} days',
|
||||
noExpiryBadge: 'No expiry',
|
||||
evaluationTooltip: 'Concludes with an EMA decision rather than a certificate',
|
||||
evaluationOnly: 'Evaluation only',
|
||||
startApplication: 'Start application',
|
||||
|
||||
Reference in New Issue
Block a user