diff --git a/apps/backoffice/src/app/features/certificate-designer/components/BlockPropertiesPanel.tsx b/apps/backoffice/src/app/features/certificate-designer/components/BlockPropertiesPanel.tsx index 8e8684350..b0f91cab9 100644 --- a/apps/backoffice/src/app/features/certificate-designer/components/BlockPropertiesPanel.tsx +++ b/apps/backoffice/src/app/features/certificate-designer/components/BlockPropertiesPanel.tsx @@ -1,5 +1,6 @@ import { ActionIcon, + Button, ColorInput, Group, NumberInput, @@ -11,7 +12,7 @@ import { TextInput, Tooltip, } from '@mantine/core'; -import { IconTrash } from '@tabler/icons-react'; +import { IconBold, IconItalic, IconTrash } from '@tabler/icons-react'; import { useTranslation } from 'react-i18next'; import type { TemplateFieldPlacement, TemplateVariable } from '@ema-platform/api'; @@ -43,7 +44,17 @@ export function BlockPropertiesPanel({ ); } - const isLiteral = block.variable === null; + const current = block; + const isLiteral = current.variable === null; + const isImage = current.type === 'image'; + const variableByKey = new Map(variables.map((v) => [v.key, v])); + + const selectVariable = (key: string | null) => { + if (!key) return; + const kind: 'text' | 'image' = + variableByKey.get(key)?.kind === 'image' ? 'image' : 'text'; + onChange({ ...current, variable: key, type: kind }); + }; return ( @@ -70,11 +81,9 @@ export function BlockPropertiesPanel({ value={isLiteral ? 'text' : 'variable'} disabled={disabled} onChange={(value) => - onChange( - value === 'text' - ? { ...block, variable: null, text: block.text ?? '' } - : { ...block, variable: variables[0]?.key ?? 'companyName' }, - ) + value === 'text' + ? onChange({ ...block, variable: null, type: 'text', text: block.text ?? '' }) + : selectVariable(variables[0]?.key ?? 'companyName') } data={[ { value: 'variable', label: t('designer.blockVariable', 'Variable') }, @@ -94,24 +103,26 @@ export function BlockPropertiesPanel({ label={t('designer.blockVariableLabel', 'Variable')} data={variables.map((variable) => ({ value: variable.key, - label: variable.label, + label: variable.kind === 'image' ? `๐Ÿ–ผ ${variable.label}` : variable.label, }))} value={block.variable} - onChange={(value) => onChange({ ...block, variable: value })} + onChange={selectVariable} searchable disabled={disabled} /> )} - onChange({ ...block, fontSize: Number(value) || 14 })} - min={4} - max={200} - disabled={disabled} - /> + {!isImage && ( + onChange({ ...block, fontSize: Number(value) || 14 })} + min={4} + max={200} + disabled={disabled} + /> + )} - - onChange({ ...block, align: value as TemplateFieldPlacement['align'] }) - } - data={[ - { value: 'left', label: t('designer.alignLeft', 'Left') }, - { value: 'center', label: t('designer.alignCenter', 'Centre') }, - { value: 'right', label: t('designer.alignRight', 'Right') }, - ]} - /> + {!isImage && ( + <> + + onChange({ ...block, align: value as TemplateFieldPlacement['align'] }) + } + data={[ + { value: 'left', label: t('designer.alignLeft', 'Left') }, + { value: 'center', label: t('designer.alignCenter', 'Centre') }, + { value: 'right', label: t('designer.alignRight', 'Right') }, + { value: 'justify', label: t('designer.alignJustify', 'Justify') }, + ]} + /> - - onChange({ ...block, fontWeight: value as TemplateFieldPlacement['fontWeight'] }) - } - data={[ - { value: 'normal', label: t('designer.weightNormal', 'Normal') }, - { value: 'bold', label: t('designer.weightBold', 'Bold') }, - ]} - /> + + + + - onChange({ ...block, color: value })} - disabled={disabled} - format="hex" - /> + onChange({ ...block, color: value })} + disabled={disabled} + format="hex" + /> + + )} ); 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 8dec84dfe..5c19085fa 100644 --- a/apps/backoffice/src/app/features/certificate-designer/components/DesignerToolbar.tsx +++ b/apps/backoffice/src/app/features/certificate-designer/components/DesignerToolbar.tsx @@ -1,13 +1,17 @@ import { Button, Group, NumberInput, Select, Tooltip } from '@mantine/core'; import { IconPlus } from '@tabler/icons-react'; import { useTranslation } from 'react-i18next'; -import { useLocalized, type LicenseType } from '@ema-platform/api'; +import { useLocalized, type LicenseType, type Rank } from '@ema-platform/api'; import { groupedTypeOptions } from '../config/designer'; interface Props { licenseTypes: LicenseType[]; typeId: string | null; onTypeChange: (id: string | null) => void; + /** The selected licence type's rank ladder โ€” empty for non-CoC/CoP types. */ + ranks: Rank[]; + rankId: string | null; + onRankChange: (id: string | null) => void; validityMonths: number; onValidityChange: (months: number) => void; currentValidityMonths?: number | null; @@ -22,6 +26,9 @@ export function DesignerToolbar({ licenseTypes, typeId, onTypeChange, + ranks, + rankId, + onRankChange, validityMonths, onValidityChange, currentValidityMonths, @@ -49,6 +56,23 @@ export function DesignerToolbar({ w={340} /> + {/* CoC/CoP only โ€” a rank can carry its own design (e.g. Master's + certificate differs from an OOW's). "Default" (null) is the design + every other rank under the type falls back to. */} + {ranks.length > 0 && ( + ({ value: op, label: op }))} + value={operator} + onChange={(v) => v && setOperator(v as Operator)} + allowDeselect={false} + /> + + {operator !== 'isSet' && operator !== 'in' && target?.field.type === 'SELECT' && ( + ({ + value: o.value, + label: localized(o.label) || o.value, + }))} + multiple={undefined} + value={null} + onChange={(v) => { + if (!v) return; + const current = (value.in ?? []) as string[]; + if (!current.includes(v)) setInValues([...current, v]); + }} + /> + )} + + {operator === 'in' && target?.field.type !== 'SELECT' && ( + + setInValues( + e.currentTarget.value + .split(',') + .map((s) => s.trim()) + .filter(Boolean), + ) + } + /> + )} + + + {operator === 'in' && (value.in?.length ?? 0) > 0 && ( + + {(value.in ?? []).map((v, i) => ( + setInValues((value.in ?? []).filter((_, idx) => idx !== i).map(String))} + title={t('certReq.condition.removeValue', 'Click to remove')} + > + {String(v)} ร— + + ))} + + )} + + {!target && value.field && ( + + {t( + 'certReq.condition.unknownField', + 'This path is not a field in the current schema yet โ€” it will still be saved as typed.', + )} + + )} + + ); +} + +const EMPTY_ARM: ConditionArm = { field: '', equals: '' }; + /** * Authors one `FieldCondition` (`showWhen` on a section/field, or * `conditionExpression` on a document requirement). @@ -38,6 +232,11 @@ function coerce(raw: string, targetType: string | undefined): string | number | * SELECT field, the value picker switches to that field's own options * instead of free text โ€” the condition can only ever reference an answer * that could actually be chosen. + * + * "Any of these" switches to authoring several single-field conditions whose + * OR is the real condition โ€” needed when the same logical value can live on + * one of several mutually-exclusive fields (e.g. a rank split by + * department, see FieldCondition.anyOf). */ export function ConditionBuilder({ value, @@ -54,40 +253,35 @@ export function ConditionBuilder({ allowClear?: boolean; }) { const { t } = useTranslation(); - const localized = useLocalized(); const active = value !== null; - const operator = operatorOf(value ?? undefined) ?? 'equals'; - const target = targets.find((c) => c.path === value?.field); - const operators = palette?.conditionOperators ?? ['equals', 'notEquals', 'in', 'isSet']; + const isAnyOf = Boolean(value?.anyOf); + const arms = (value?.anyOf ?? []) as ConditionArm[]; - function setField(field: string) { - onChange({ field, ...(operator === 'isSet' ? { isSet: true } : { equals: '' }) }); + function setArm(i: number, arm: ConditionArm) { + const next = arms.slice(); + next[i] = arm; + onChange({ anyOf: next }); } - function setOperator(next: Operator) { - if (!value?.field) return; - const base: ConditionValue = { field: value.field }; - if (next === 'isSet') base.isSet = true; - else if (next === 'in') base.in = []; - else if (next === 'notEquals') base.notEquals = ''; - else base.equals = ''; - onChange(base); + function addArm() { + onChange({ anyOf: [...arms, { ...EMPTY_ARM }] }); } - function setValueRaw(raw: string) { - if (!value?.field) return; - const coerced = coerce(raw, target?.field.type); - if (operator === 'equals') onChange({ field: value.field, equals: coerced }); - else if (operator === 'notEquals') onChange({ field: value.field, notEquals: coerced }); + function removeArm(i: number) { + onChange({ anyOf: arms.filter((_, idx) => idx !== i) }); } - function setInValues(raws: string[]) { - if (!value?.field) return; - onChange({ - field: value.field, - in: raws.map((r) => coerce(r, target?.field.type)) as (string | number)[], - }); + function toggleAnyOf(next: boolean) { + if (next) { + // Seed the list from whatever single condition already existed, so + // switching modes doesn't discard work in progress. + const seed: ConditionArm = value?.field ? (value as ConditionArm) : { ...EMPTY_ARM }; + onChange({ anyOf: [seed] }); + } else { + // Same, in reverse โ€” the first arm becomes the single condition. + onChange((arms[0] as ConditionValue) ?? { field: '', equals: '' }); + } } return ( @@ -102,116 +296,58 @@ export function ConditionBuilder({ {active && ( - c.path)} - value={value?.field ?? ''} - onChange={setField} + checked={isAnyOf} + onChange={(e) => toggleAnyOf(e.currentTarget.checked)} /> - - ({ - value: o.value, - label: localized(o.label) || o.value, - }))} - value={String(value?.equals ?? value?.notEquals ?? '')} - onChange={(v) => v !== null && setValueRaw(v)} - /> - )} - - {operator !== 'isSet' && operator !== 'in' && target?.field.type !== 'SELECT' && ( - target?.field.type === 'BOOLEAN' ? ( - setValueRaw(String(e.currentTarget.checked))} - /> - ) : ( - setValueRaw(e.currentTarget.value)} - /> - ) - )} - - {operator === 'in' && target?.field.type === 'SELECT' && ( - + ({ - value: o.value, - label: localized(o.label), - }))} + data={selectOptions(field, value as string | undefined, departments, ranks, localized)} value={(value as string) ?? null} onChange={(v) => onChange(field.key, v)} clearable={!field.required} diff --git a/apps/portal/src/app/features/seafarer-registration/components/RegistrationSummary.tsx b/apps/portal/src/app/features/seafarer-registration/components/RegistrationSummary.tsx index 80a72c793..d8a180a29 100644 --- a/apps/portal/src/app/features/seafarer-registration/components/RegistrationSummary.tsx +++ b/apps/portal/src/app/features/seafarer-registration/components/RegistrationSummary.tsx @@ -3,6 +3,8 @@ import { SEAFARER_REGISTRATION_FIELD_LABELS, SEAFARER_REGISTRATION_SECTIONS, displaySeafarerAnswer, + useGetActiveDepartmentsQuery, + useLocalized, type Attachment, type SaveSeafarerRegistration, } from '@ema-platform/api'; @@ -16,6 +18,10 @@ export function RegistrationSummary({ answers: SaveSeafarerRegistration; attachments?: Attachment[]; }) { + const localized = useLocalized(); + const { data: departments } = useGetActiveDepartmentsQuery(); + const departmentOptions = departments?.map((d) => ({ value: d.code, label: localized(d.name) })); + return ( {SEAFARER_REGISTRATION_SECTIONS.map((section) => ( @@ -35,7 +41,9 @@ export function RegistrationSummary({ - {displaySeafarerAnswer(field, answers[field])} + + {displaySeafarerAnswer(field, answers[field], departmentOptions)} + ))} diff --git a/apps/portal/src/app/features/seafarer-registration/components/steps.tsx b/apps/portal/src/app/features/seafarer-registration/components/steps.tsx index d75e7f585..bccc92480 100644 --- a/apps/portal/src/app/features/seafarer-registration/components/steps.tsx +++ b/apps/portal/src/app/features/seafarer-registration/components/steps.tsx @@ -6,6 +6,8 @@ import { GENDER_OPTIONS, HAIR_COLOR_OPTIONS, MARITAL_STATUS_OPTIONS, + useGetActiveDepartmentsQuery, + useLocalized, } from '@ema-platform/api'; import { DateField, @@ -72,8 +74,21 @@ export function IdentityDetailsStep( ); } -/** Step 2 โ€” Identity, Address and Physical Characteristics. */ +/** + * Step 2 โ€” Identity, Address and Physical Characteristics. + * + * The department list is backoffice-managed (see the Ranks & Departments + * configuration tab), so this fetches the live set rather than a fixed + * three โ€” falling back to it only until the query resolves, so the field + * is never an empty flash. + */ export function ApplicantDetailsStep(p: StepProps) { + const localized = useLocalized(); + const { data: departments } = useGetActiveDepartmentsQuery(); + const departmentOptions = + departments?.map((d) => ({ value: d.code, label: localized(d.name) })) ?? + DEPARTMENT_OPTIONS; + return ( @@ -94,7 +109,7 @@ export function ApplicantDetailsStep(p: StepProps) { name="department" label="Department" required - options={DEPARTMENT_OPTIONS} + options={departmentOptions} description="The STCW department you serve in. Determines which certificates, examinations and services apply to you." /> diff --git a/libs/api/src/lib/base-api/base-query-with-reauth.ts b/libs/api/src/lib/base-api/base-query-with-reauth.ts index 6f27c1b4f..c47336cd8 100644 --- a/libs/api/src/lib/base-api/base-query-with-reauth.ts +++ b/libs/api/src/lib/base-api/base-query-with-reauth.ts @@ -5,7 +5,7 @@ import { resolveSessionContext } from "../session"; export const BASE_API_URL = (import.meta as { env?: Record }).env?.[ "VITE_BASE_API_URL" - ] ?? "http://localhost:3001/api"; + ] ?? "http://localhost:3000/api"; let _onTokenExpired: (() => Promise) | null = null; let _onAuthFailure: (() => void) | null = null; diff --git a/libs/api/src/lib/features/licensing/licensing-api.ts b/libs/api/src/lib/features/licensing/licensing-api.ts index 6a960d0fa..3e17464ff 100644 --- a/libs/api/src/lib/features/licensing/licensing-api.ts +++ b/libs/api/src/lib/features/licensing/licensing-api.ts @@ -6,6 +6,7 @@ import type { ApplicationPayment, ApplicationStaff, Attachment, + Department, DocumentRequirement, FormSchemaPalette, FormSectionConfig, @@ -27,6 +28,8 @@ import type { Paginated, QueueCounts, QueueFilter, + Rank, + RankCertificateCategory, RemarkTargetType, SavedQueueView, SchemaIssue, @@ -72,6 +75,8 @@ const TAGS = [ 'SavedView', 'LicenseTemplate', 'DocumentRequirement', + 'Department', + 'Rank', ] as const; const listTag = (type: (typeof TAGS)[number]) => ({ type, id: 'LIST' }) as const; @@ -254,6 +259,75 @@ export const licensingApi = baseApi invalidatesTags: (_r, error) => (error ? [] : [listTag('DocumentRequirement')]), }), + // --------------------------------------------------- departments & ranks + /** Every department, for the admin editor. */ + getDepartments: builder.query, void>({ + query: () => ({ url: '/departments' }), + providesTags: () => [listTag('Department')], + }), + + /** Active departments only โ€” the applicant-facing picker. */ + getActiveDepartments: builder.query({ + query: () => ({ url: '/departments/active/list' }), + providesTags: () => [listTag('Department')], + }), + + createDepartment: builder.mutation< + Department, + Partial & { code: string; name: Department['name'] } + >({ + query: (body) => ({ url: '/departments', method: 'POST', body }), + invalidatesTags: (_r, error) => (error ? [] : [listTag('Department')]), + }), + + updateDepartment: builder.mutation>({ + query: ({ id, ...body }) => ({ url: `/departments/${id}`, method: 'PUT', body }), + invalidatesTags: (_r, error) => (error ? [] : [listTag('Department')]), + }), + + deleteDepartment: builder.mutation({ + query: (id) => ({ url: `/departments/${id}`, method: 'DELETE' }), + invalidatesTags: (_r, error) => (error ? [] : [listTag('Department')]), + }), + + /** Every rank, for the admin editor to filter/group by department client-side. */ + getRanks: builder.query, void>({ + query: () => ({ url: '/ranks' }), + providesTags: () => [listTag('Rank')], + }), + + /** One department's ladder for a category, ordered โ€” the applicant wizard's rank picker. */ + getRankLadder: builder.query< + Rank[], + { departmentId: string; certificateCategory: RankCertificateCategory } + >({ + query: (params) => ({ url: '/ranks/ladder', params }), + providesTags: () => [listTag('Rank')], + }), + + createRank: builder.mutation< + Rank, + Partial & { + departmentId: string; + certificateCategory: RankCertificateCategory; + key: string; + name: Rank['name']; + } + >({ + query: (body) => ({ url: '/ranks', method: 'POST', body }), + invalidatesTags: (_r, error) => (error ? [] : [listTag('Rank')]), + }), + + updateRank: builder.mutation>({ + query: ({ id, ...body }) => ({ url: `/ranks/${id}`, method: 'PUT', body }), + invalidatesTags: (_r, error) => (error ? [] : [listTag('Rank')]), + }), + + deleteRank: builder.mutation({ + query: (id) => ({ url: `/ranks/${id}`, method: 'DELETE' }), + invalidatesTags: (_r, error) => (error ? [] : [listTag('Rank')]), + }), + // -------------------------------------------------------- application createApplication: builder.mutation< LicenseApplication, @@ -641,6 +715,8 @@ export const licensingApi = baseApi LicenseTemplate, { licenseTypeId: string; + /** Scopes the draft to one rank's certificate. Omit for the type's default design. */ + rankId?: string | null; name: string; hbsSource?: string; pageOptions?: TemplatePageOptions; @@ -654,6 +730,7 @@ export const licensingApi = baseApi LicenseTemplate, { id: string; + rankId?: string | null; name?: string; hbsSource?: string; pageOptions?: TemplatePageOptions; @@ -919,6 +996,16 @@ export const { useCreateDocumentRequirementMutation, useUpdateDocumentRequirementMutation, useDeleteDocumentRequirementMutation, + useGetDepartmentsQuery, + useGetActiveDepartmentsQuery, + useCreateDepartmentMutation, + useUpdateDepartmentMutation, + useDeleteDepartmentMutation, + useGetRanksQuery, + useGetRankLadderQuery, + useCreateRankMutation, + useUpdateRankMutation, + useDeleteRankMutation, useUpdateLicenseValidityMutation, useGetLicenseTypeRequirementsQuery, useCreateApplicationMutation, diff --git a/libs/api/src/lib/features/licensing/licensing.helpers.ts b/libs/api/src/lib/features/licensing/licensing.helpers.ts index 248de738b..ecb0218d4 100644 --- a/libs/api/src/lib/features/licensing/licensing.helpers.ts +++ b/libs/api/src/lib/features/licensing/licensing.helpers.ts @@ -558,14 +558,25 @@ export function validateSections( } /** Evaluates a config condition against the current form answers. */ +interface ConditionLike { + field?: string; + equals?: unknown; + notEquals?: unknown; + in?: (string | number)[]; + isSet?: boolean; + /** Holds when ANY listed sub-condition holds โ€” see FieldCondition.anyOf. */ + anyOf?: ConditionLike[]; +} + export function conditionHolds( - condition: - | { field: string; equals?: unknown; notEquals?: unknown; in?: (string | number)[]; isSet?: boolean } - | undefined - | null, + condition: ConditionLike | undefined | null, formData: Record>, ): boolean { - if (!condition?.field) return true; + if (!condition) return true; + if (condition.anyOf) { + return condition.anyOf.some((sub) => conditionHolds(sub, formData)); + } + if (!condition.field) return true; const value = condition.field .split('.') .reduce( diff --git a/libs/api/src/lib/features/licensing/licensing.types.ts b/libs/api/src/lib/features/licensing/licensing.types.ts index 22bef0df4..57a0919c2 100644 --- a/libs/api/src/lib/features/licensing/licensing.types.ts +++ b/libs/api/src/lib/features/licensing/licensing.types.ts @@ -67,11 +67,18 @@ export type FormFieldType = | "TIN"; export interface FieldCondition { - field: string; + /** Omitted when `anyOf` is used instead โ€” see below. */ + field?: string; equals?: string | number | boolean; notEquals?: string | number | boolean; in?: (string | number)[]; isSet?: boolean; + /** + * Holds when ANY listed condition holds โ€” for a value that can live on one + * of several mutually-exclusive fields (e.g. a rank split by department). + * `field`/`equals`/etc are ignored when this is present. + */ + anyOf?: FieldCondition[]; } export interface FormFieldConfig { @@ -565,19 +572,50 @@ export interface TemplateFieldPlacement { /** Variable rendered here, or null when the block carries literal `text`. */ variable: string | null; text?: string; + /** Renders as `` when "image" โ€” see TemplateVariable.kind. */ + type?: "text" | "image"; xPct: number; yPct: number; widthPct: number; fontSize?: number; fontWeight?: "normal" | "bold"; - align?: "left" | "center" | "right"; + fontStyle?: "normal" | "italic"; + align?: "left" | "center" | "right" | "justify"; color?: string; } /** A certificate design authored in the backoffice. */ +/** An STCW seafarer department (Deck, Engine, Catering), backoffice-managed. */ +export interface Department { + id: string; + /** Matches the ESeafarerDepartment value stored elsewhere, e.g. "DECK". */ + code: string; + name: Bilingual; + sortOrder: number; + isActive: boolean; +} + +export type RankCertificateCategory = "COC" | "COP"; + +/** One rung of a CoC/CoP ladder for a department. */ +export interface Rank { + id: string; + departmentId: string; + certificateCategory: RankCertificateCategory; + /** Value stored on License.rank / Certification.rankKey, e.g. "CHIEF_MATE". */ + key: string; + name: Bilingual; + /** Rung position within its department+category ladder. 0 is the entry rank. */ + ladderOrder: number; + sortOrder: number; + isActive: boolean; +} + export interface LicenseTemplate { id: string; licenseTypeId: string; + /** Scopes this design to one rank's certificate. Null = the type's default. */ + rankId?: string | null; name: string; version: number; hbsSource: string; @@ -604,6 +642,8 @@ export interface LicenseTemplate { export interface TemplateVariable { key: string; label: string; + /** "image" means the value is a data URI to place as ``, not text. */ + kind?: "text" | "image"; } export interface Paginated { diff --git a/libs/api/src/lib/features/seafarer-registration/seafarer-registration.constants.ts b/libs/api/src/lib/features/seafarer-registration/seafarer-registration.constants.ts index e3406a4f0..1112e2bdf 100644 --- a/libs/api/src/lib/features/seafarer-registration/seafarer-registration.constants.ts +++ b/libs/api/src/lib/features/seafarer-registration/seafarer-registration.constants.ts @@ -210,14 +210,22 @@ const OPTION_LABELS: Partial o.value === value)?.label ?? String(value); return String(value); }