feat: add REISSUE application kind and dynamic file size limits, and remove unused serviceKind logic

This commit is contained in:
estifanos
2026-09-02 10:31:18 +00:00
parent 51aa446eee
commit 92f2f16b80
7 changed files with 45 additions and 31 deletions

View File

@@ -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,
@@ -154,12 +151,22 @@ export function BehaviorTab({ licenseType }: { licenseType: LicenseType }) {
...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,
...rest,
...(expires ? { validityMonths, validityDays } : {}),
...term,
...(expires && draft.renewalEnabled
? { renewalWindowDays, expiryReminderDays }
: {}),
@@ -241,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')}>

View File

@@ -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 }))}

View File

@@ -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"

View File

@@ -1496,6 +1496,7 @@ export const am: Translations = {
applicationKind: "የማመልከቻ ዓይነት",
kindNew: "አዲስ ማመልከቻ",
kindRenewal: "ዕድሳት",
kindReissue: "የተበላሸ / ምትክ",
kindLocked: "ከተፈጠረ በኋላ የማመልከቻ ዓይነት መቀየር አይቻልም",
mode: "ዘዴ",
modeAlways: "ሁልጊዜ ያስፈልጋል",

View File

@@ -1502,6 +1502,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',

View File

@@ -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`,

View File

@@ -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 },