diff --git a/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/columns.tsx b/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/columns.tsx index ed0bf3e91..5ea6c3086 100644 --- a/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/columns.tsx +++ b/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/columns.tsx @@ -2,7 +2,6 @@ import type { Dispatch, ReactNode, SetStateAction } from "react"; import { Badge, Checkbox, Text, Tooltip } from "@mantine/core"; import type { TFunction } from "i18next"; import { - APPLICANT_NAME_TYPE_KEYS, STATUS_COLORS, STATUS_LABELS, applicantOrCompanyName, @@ -14,11 +13,28 @@ import type { AdvancedColumn } from "@ema-platform/ui"; import { dateDisplayer } from "@ema-platform/shared"; import { computeSla } from "../../sla"; +/** + * Label for the Company/Applicant column, derived from the rows actually on + * screen rather than the route — a type-pinned queue (`/type/:typeCode`) + * happens to be one family, but nothing stops the mixed "All Applications" + * grid from holding both, and a static header can't be correct for both at + * once. Falls back to the combined label until the page has data to look at. + */ +function companyColumnHeader(t: TFunction, items: LicenseApplication[]): string { + if (items.length === 0) { + return t("queue.companyOrApplicant", "Applicant / Company"); + } + const allLogistics = items.every((a) => a.familyKind === "LOGISTICS_LICENSE"); + const allNonLogistics = items.every((a) => a.familyKind !== "LOGISTICS_LICENSE"); + if (allLogistics) return t("queue.company", "Company"); + if (allNonLogistics) return t("queue.applicant", "Applicant"); + return t("queue.companyOrApplicant", "Applicant / Company"); +} + export function licenseQueueColumns( t: TFunction, locale: string, opts: { - typeCode: string | undefined; items: LicenseApplication[]; selected: string[]; setSelected: Dispatch>; @@ -29,8 +45,7 @@ export function licenseQueueColumns( ) => ReactNode; }, ): AdvancedColumn[] { - const { typeCode, items, selected, setSelected, allSelected, sortableHeader } = - opts; + const { items, selected, setSelected, allSelected, sortableHeader } = opts; return [ { header: ( @@ -72,12 +87,13 @@ export function licenseQueueColumns( ), }, { - header: sortableHeader( - typeCode && APPLICANT_NAME_TYPE_KEYS.includes(typeCode) - ? t("queue.applicant", "Applicant") - : t("queue.company", "Company"), - "companyName", - ), + // Header reflects what's actually on screen, not the route: a + // type-pinned queue (`typeCode` set) is always one family, but the + // mixed "All Applications" grid can hold logistics rows and + // certificate/document rows side by side, so no single static label is + // right for the whole column there — "Applicant / Company" covers + // both without claiming a row is one or the other. + header: sortableHeader(companyColumnHeader(t, items), "companyName"), label: t("queue.company", "Company"), cell: ({ row }) => ( {applicantOrCompanyName(row.original) ?? "—"} @@ -85,11 +101,17 @@ export function licenseQueueColumns( }, { header: t("queue.tin", "TIN"), - cell: ({ row }) => ( - - {row.original.tinNumber ?? "—"} - - ), + cell: ({ row }) => + // A certificate/document application is filed by a person, never a + // business — there is no TIN to show, not even a blank one. Hiding + // the cell (rather than "—") is what makes the row visibly not a + // logistics-licence row, which is the whole point of `familyKind` + // being a real column now instead of a frontend guess. + row.original.familyKind === "LOGISTICS_LICENSE" ? ( + + {row.original.tinNumber ?? "—"} + + ) : null, }, { header: t("queue.typeCol", "Type"), diff --git a/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/index.tsx b/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/index.tsx index 41ba5fd9e..11b03f602 100644 --- a/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/index.tsx +++ b/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/index.tsx @@ -375,7 +375,6 @@ export function LicenseQueuePage() { const columns: AdvancedColumn[] = useMemo( () => [ ...licenseQueueColumns(t, i18n.language, { - typeCode, items, selected, setSelected, @@ -397,7 +396,6 @@ export function LicenseQueuePage() { allSelected, items, claiming, - typeCode, ], ); diff --git a/apps/backoffice/src/app/i18n/locales/am.ts b/apps/backoffice/src/app/i18n/locales/am.ts index b6917b5ce..45275b472 100644 --- a/apps/backoffice/src/app/i18n/locales/am.ts +++ b/apps/backoffice/src/app/i18n/locales/am.ts @@ -823,6 +823,7 @@ export const am: Translations = { number: "ማመልከቻ ቁ.", company: "ኩባንያ", applicant: "አመልካች", + companyOrApplicant: "አመልካች / ኩባንያ", tin: "ቲን", submitted: "የቀረበበት", sla: "ዕድሜ / የጊዜ ገደብ", diff --git a/apps/backoffice/src/app/i18n/locales/en.ts b/apps/backoffice/src/app/i18n/locales/en.ts index 23f2b4677..c8efed7ea 100644 --- a/apps/backoffice/src/app/i18n/locales/en.ts +++ b/apps/backoffice/src/app/i18n/locales/en.ts @@ -827,6 +827,10 @@ export const en = { number: 'App #', company: 'Company', applicant: 'Applicant', + // Column header when the grid holds both logistics-licence rows (which + // have a company) and certificate/document rows (which have an + // applicant instead) — the mixed "All Applications" queue. + companyOrApplicant: 'Applicant / Company', tin: 'TIN', submitted: 'Submitted', sla: 'Age / SLA', diff --git a/apps/portal/src/app/features/licensing/components/LicenseCatalogue.tsx b/apps/portal/src/app/features/licensing/components/LicenseCatalogue.tsx index e53367d8a..62d7c3cc9 100644 --- a/apps/portal/src/app/features/licensing/components/LicenseCatalogue.tsx +++ b/apps/portal/src/app/features/licensing/components/LicenseCatalogue.tsx @@ -79,10 +79,15 @@ export function LicenseCatalogue() { const { groups, orphans } = useMemo(() => { const active = (types?.items ?? []) .filter((t) => t.isActive) - // Person-centric registrations (seafarer) are not operator licences: - // they can never be declared as a mode, have their own entry points, - // and would only confuse this catalogue — even under "show all". - .filter((t) => t.requiresOperatorMode !== false) + // Logistics licences only: this is the operator catalogue, not the + // seafarer certificate or vessel/seafarer document catalogue — those + // have their own entry points. `familyKind` is the real data-model + // classification (set on the type at seed time); `requiresOperatorMode` + // was the proxy this used before that column existed and happened to + // agree for every type seeded so far, but a type can only be trusted to + // stay in sync with the catalogue it belongs in if the catalogue reads + // its actual family instead of a flag with a different purpose. + .filter((t) => t.familyKind === 'LOGISTICS_LICENSE') // Only what the applicant operates as. The server enforces the same rule // on create; this is what stops them starting an application they will // be refused at the end of. diff --git a/libs/api/src/lib/features/licensing/licensing.helpers.ts b/libs/api/src/lib/features/licensing/licensing.helpers.ts index b369b115f..eac9a3e1d 100644 --- a/libs/api/src/lib/features/licensing/licensing.helpers.ts +++ b/libs/api/src/lib/features/licensing/licensing.helpers.ts @@ -1,6 +1,7 @@ import { resolveTokenFromStorage } from '../../session'; import type { Bilingual, + FamilyKind, FormFieldConfig, FormSectionConfig, LicenseApplication, @@ -172,21 +173,6 @@ export const APPLICANT_NAME_TYPE_KEYS = [ 'ENDORSEMENT_GOC', ]; -/** - * Which business concept a licence type actually is, for terminology and - * navigation only — never a workflow or eligibility branch. Mirrors - * `resolveFamilyKind` in emaapi's `common/utils/family-terminology.ts`; the - * two must be kept in step by hand since the value isn't sent over the wire - * (nothing needs it server-side beyond notification copy, so a shared - * package felt like more plumbing than the duplication it would save). - * - * A permission granted to a logistics operator, a seafarer's proof of - * competence, and a seafarer/vessel's identity or statutory record are three - * different things to the people using this system, even though they run - * through the identical application pipeline — see BR-MTO-020. - */ -export type FamilyKind = 'LOGISTICS_LICENSE' | 'CERTIFICATE' | 'DOCUMENT'; - const FAMILY_KIND_BY_KEY: Partial> = { SEAMAN_BOOK: 'DOCUMENT', VESSEL_REGISTRATION: 'DOCUMENT', @@ -203,10 +189,14 @@ const FAMILY_KIND_BY_KEY: Partial> = { }; /** - * `FamilyKind` for a type key that may not be in the map yet. Falls back to - * `LOGISTICS_LICENSE`, reproducing today's "Licence ___" wording — the safe - * default for an unrecognised key, matching how the map above treats config - * it doesn't know about. + * `FamilyKind` for a type key, for data that predates `familyKind` being a + * real column on the server (cached responses from before the rollout, or + * anything that only ever had a bare key to go on). Every fresh response now + * carries `familyKind` directly from the server's own `family_kind` column — + * prefer that over calling this. Kept only as a fallback, and only for keys + * this map happens to know about; falls back further to `LOGISTICS_LICENSE` + * for anything else, reproducing today's "Licence ___" wording — the safe + * default for an unrecognised key. */ export function resolveFamilyKind(licenseTypeKey: string | undefined | null): FamilyKind { return (licenseTypeKey && FAMILY_KIND_BY_KEY[licenseTypeKey]) || 'LOGISTICS_LICENSE'; @@ -248,9 +238,21 @@ export function familyLabels(familyKind: FamilyKind): FamilyLabels { return FAMILY_LABELS[familyKind]; } -/** Company name, or applicant name for licence types that have no company. */ +/** + * Company name, or applicant name for applications with no company. + * + * Branches on `familyKind` — the real data-model column — rather than a + * hand-maintained key list. A certificate or document is filed by a person, + * never a business, so it never has a `companyName` to show; a logistics + * licence always does. This is what used to be `APPLICANT_NAME_TYPE_KEYS`, a + * frontend array that had to be remembered and kept in sync by hand every + * time a new certificate/document type was added — it silently missed + * `SEAMAN_BOOK`/`BTC_BASIC_TRAINING`, which is why those rows rendered "—" + * instead of the applicant's name in the backoffice queue. `familyKind` is + * correct for every current and future type without a matching array update. + */ export function applicantOrCompanyName(app: LicenseApplication): string | undefined { - if (!app.licenseType?.key || !APPLICANT_NAME_TYPE_KEYS.includes(app.licenseType.key)) { + if (app.familyKind === 'LOGISTICS_LICENSE') { return app.companyName ?? undefined; } const applicantName = (app.formData?.account as Record | undefined) diff --git a/libs/api/src/lib/features/licensing/licensing.types.ts b/libs/api/src/lib/features/licensing/licensing.types.ts index d9d34f9dc..38958b43b 100644 --- a/libs/api/src/lib/features/licensing/licensing.types.ts +++ b/libs/api/src/lib/features/licensing/licensing.types.ts @@ -109,6 +109,19 @@ export type LicenseCategory = | "VESSEL_SERVICES" | "WAIVER_SERVICES"; +/** + * Which business concept a licence type actually is — the real data-model + * classification (emaapi's `license_types.family_kind` column), not a label + * computed from `key`. A permission granted to a logistics operator, a + * seafarer's proof of competence, and a seafarer/vessel's identity or + * statutory record are three different things to the people using this + * system, even though they run through the identical application pipeline — + * see BR-MTO-020. Drives terminology, navigation, and which columns a grid + * shows (Company/TIN only make sense for LOGISTICS_LICENSE rows) — never a + * workflow or eligibility branch. + */ +export type FamilyKind = "LOGISTICS_LICENSE" | "CERTIFICATE" | "DOCUMENT"; + export interface LicenseCategoryDefinition { key: LicenseCategory; name: Bilingual; @@ -135,6 +148,8 @@ export interface LicenseType { name: Bilingual; description?: Bilingual; category: LicenseCategory; + /** The real data-model classification — see `FamilyKind`. */ + familyKind: FamilyKind; certificatePrefix: string; feeNewApplication: string | number | null; feeRenewal: string | number | null; @@ -257,6 +272,8 @@ export interface LicenseApplication { applicationNumber: string; licenseTypeId: string; licenseType?: LicenseType; + /** Denormalized from `licenseType.familyKind` at submission time. */ + familyKind: FamilyKind; applicantUserId: string; parentApplicationId?: string | null; kind: ApplicationKind; @@ -611,6 +628,8 @@ export interface IssuedLicense { certificateNumber: string; licenseTypeId: string; licenseType?: LicenseType; + /** Denormalized from `licenseType.familyKind` at issuance time. */ + familyKind: FamilyKind; applicationId: string; companyName: string | null; tinNumber: string | null;