diff --git a/apps/backoffice/src/app/features/configuration/pages/ConfigurationPage/RankDepartmentTab.tsx b/apps/backoffice/src/app/features/configuration/pages/ConfigurationPage/RankDepartmentTab.tsx
new file mode 100644
index 000000000..f81c4a303
--- /dev/null
+++ b/apps/backoffice/src/app/features/configuration/pages/ConfigurationPage/RankDepartmentTab.tsx
@@ -0,0 +1,554 @@
+import { useCallback, useState } from "react";
+import {
+ Alert,
+ Button,
+ Group,
+ Modal,
+ NumberInput,
+ Select,
+ Stack,
+ Text,
+ TextInput,
+ Title,
+} from "@mantine/core";
+import { useForm } from "@mantine/form";
+import { useDisclosure } from "@mantine/hooks";
+import { IconInfoCircle, IconPlus } from "@tabler/icons-react";
+import { useTranslation } from "react-i18next";
+import {
+ AdvancedTable,
+ ModalFooter,
+ notify,
+ PageLoader,
+ useErrorHandler,
+ type AdvancedColumn,
+} from "@ema-platform/ui";
+import {
+ useLocalized,
+ useGetDepartmentsQuery,
+ useCreateDepartmentMutation,
+ useUpdateDepartmentMutation,
+ useDeleteDepartmentMutation,
+ useGetRanksQuery,
+ useCreateRankMutation,
+ useUpdateRankMutation,
+ useDeleteRankMutation,
+ type Department,
+ type Rank,
+ type RankCertificateCategory,
+} from "@ema-platform/api";
+
+const CATEGORY_OPTIONS: { value: RankCertificateCategory; label: string }[] = [
+ { value: "COC", label: "CoC" },
+ { value: "COP", label: "CoP" },
+];
+
+/**
+ * Departments and their CoC/CoP rank ladders, as backoffice-editable config.
+ *
+ * Both used to be hardcoded (`ESeafarerDepartment` and the `COC_LADDERS`/
+ * `COP_LADDERS` arrays) — this is the write side that config never had. A
+ * rank's `ladderOrder` is the rung position `resolveNextRank` climbs, so
+ * reordering here changes what an applicant is auto-advanced to next.
+ */
+export function RankDepartmentTab() {
+ const { t } = useTranslation();
+ const localized = useLocalized();
+
+ const { data: deptRes, isLoading: deptLoading, isFetching: deptFetching, refetch: refetchDepts } =
+ useGetDepartmentsQuery();
+ const { data: rankRes, isLoading: rankLoading, isFetching: rankFetching, refetch: refetchRanks } =
+ useGetRanksQuery();
+
+ const departments = deptRes?.items ?? [];
+ const ranks = rankRes?.items ?? [];
+
+ const deptOptions = departments.map((d) => ({ value: d.id, label: localized(d.name) }));
+ const deptName = useCallback(
+ (id: string) => departments.find((d) => d.id === id)?.code ?? "-",
+ [departments],
+ );
+
+ if (deptLoading || rankLoading) {
+ return ;
+ }
+
+ return (
+
+ }>
+
+ {t(
+ "configuration.rankLadderNotice",
+ "A rank's position is its rung on the ladder — an applicant is auto-advanced to the next position up from what they already hold.",
+ )}
+
+
+
+
+
+
+
+ );
+}
+
+// ------------------------------------------------------------- departments
+
+function DepartmentSection({
+ departments,
+ isFetching,
+ refetch,
+ localized,
+}: {
+ departments: Department[];
+ isFetching: boolean;
+ refetch: () => void;
+ localized: (v: Department["name"]) => string;
+}) {
+ const { t } = useTranslation();
+ const { handleError } = useErrorHandler();
+ const [createDepartment, { isLoading: isCreating }] = useCreateDepartmentMutation();
+ const [updateDepartment, { isLoading: isUpdating }] = useUpdateDepartmentMutation();
+ const [deleteDepartment] = useDeleteDepartmentMutation();
+
+ const [editing, setEditing] = useState(null);
+ const [showForm, setShowForm] = useState(false);
+ const [deleteTarget, setDeleteTarget] = useState(null);
+ const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false);
+
+ const resetForm = useCallback(() => {
+ setEditing(null);
+ setShowForm(false);
+ }, []);
+
+ const form = useForm({
+ initialValues: { code: "", nameEn: "", nameAm: "", sortOrder: 0 },
+ validate: {
+ code: (v) => (!v ? t("configuration.validation.codeRequired", "Code is required") : null),
+ nameEn: (v) => (!v ? t("configuration.validation.nameEnRequired") : null),
+ nameAm: (v) => (!v ? t("configuration.validation.nameAmRequired") : null),
+ },
+ });
+
+ const openEdit = useCallback(
+ (dept: Department) => {
+ setEditing(dept);
+ form.setValues({
+ code: dept.code,
+ nameEn: dept.name.en ?? "",
+ nameAm: dept.name.am ?? "",
+ sortOrder: dept.sortOrder,
+ });
+ setShowForm(true);
+ },
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [],
+ );
+
+ const handleSubmit = form.onSubmit(async (values) => {
+ const name = { en: values.nameEn, am: values.nameAm };
+ try {
+ if (editing) {
+ await updateDepartment({
+ id: editing.id,
+ code: values.code,
+ name,
+ sortOrder: values.sortOrder,
+ }).unwrap();
+ notify.success(t("configuration.updated"));
+ } else {
+ await createDepartment({ code: values.code, name, sortOrder: values.sortOrder }).unwrap();
+ notify.success(t("configuration.created"));
+ }
+ resetForm();
+ form.reset();
+ } catch (e) {
+ handleError(e);
+ }
+ });
+
+ const confirmDelete = useCallback(async () => {
+ if (!deleteTarget) return;
+ try {
+ await deleteDepartment(deleteTarget.id).unwrap();
+ notify.success(t("configuration.deleted"));
+ closeDelete();
+ setDeleteTarget(null);
+ } catch (e) {
+ handleError(e);
+ }
+ }, [deleteTarget, deleteDepartment, closeDelete, handleError]);
+
+ const columns: AdvancedColumn[] = [
+ { header: t("configuration.code", "Code"), cell: ({ row }) => row.original.code },
+ { header: t("configuration.name"), cell: ({ row }) => localized(row.original.name) },
+ { header: t("configuration.sortOrder", "Order"), cell: ({ row }) => row.original.sortOrder },
+ {
+ header: "actions",
+ size: 90,
+ cell: ({ row }) => (
+
+
+
+
+ ),
+ },
+ ];
+
+ return (
+
+
+ {t("configuration.departments", "Departments")}
+ {!showForm && (
+ }
+ onClick={() => {
+ form.reset();
+ setShowForm(true);
+ }}
+ >
+ {t("configuration.addDepartment", "Add department")}
+
+ )}
+
+
+ a.sortOrder - b.sortOrder)}
+ tableName={t("configuration.departments", "Departments")}
+ itemCount={departments.length}
+ pageIndex={0}
+ onPageChange={() => {}}
+ pageSize={departments.length || 10}
+ onPageSizeChange={() => {}}
+ refresh={refetch}
+ isLoading={isFetching}
+ />
+
+
+
+
+
+
+
+ {t("configuration.deleteConfirmText", { name: deleteTarget ? localized(deleteTarget.name) : "" })}
+
+
+
+
+
+
+
+ );
+}
+
+// ------------------------------------------------------------------ ranks
+
+function RankSection({
+ ranks,
+ deptOptions,
+ deptName,
+ isFetching,
+ refetch,
+ localized,
+}: {
+ ranks: Rank[];
+ deptOptions: { value: string; label: string }[];
+ deptName: (id: string) => string;
+ isFetching: boolean;
+ refetch: () => void;
+ localized: (v: Rank["name"]) => string;
+}) {
+ const { t } = useTranslation();
+ const { handleError } = useErrorHandler();
+ const [createRank, { isLoading: isCreating }] = useCreateRankMutation();
+ const [updateRank, { isLoading: isUpdating }] = useUpdateRankMutation();
+ const [deleteRank] = useDeleteRankMutation();
+
+ const [editing, setEditing] = useState(null);
+ const [showForm, setShowForm] = useState(false);
+ const [deleteTarget, setDeleteTarget] = useState(null);
+ const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false);
+
+ const resetForm = useCallback(() => {
+ setEditing(null);
+ setShowForm(false);
+ }, []);
+
+ const form = useForm({
+ initialValues: {
+ departmentId: "",
+ certificateCategory: "COC" as RankCertificateCategory,
+ key: "",
+ nameEn: "",
+ nameAm: "",
+ ladderOrder: 0,
+ },
+ validate: {
+ departmentId: (v) => (!v ? t("configuration.validation.departmentRequired") : null),
+ key: (v) => (!v ? t("configuration.validation.keyRequired", "Key is required") : null),
+ nameEn: (v) => (!v ? t("configuration.validation.nameEnRequired") : null),
+ nameAm: (v) => (!v ? t("configuration.validation.nameAmRequired") : null),
+ },
+ });
+
+ const openEdit = useCallback(
+ (rank: Rank) => {
+ setEditing(rank);
+ form.setValues({
+ departmentId: rank.departmentId,
+ certificateCategory: rank.certificateCategory,
+ key: rank.key,
+ nameEn: rank.name.en ?? "",
+ nameAm: rank.name.am ?? "",
+ ladderOrder: rank.ladderOrder,
+ });
+ setShowForm(true);
+ },
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [],
+ );
+
+ const handleSubmit = form.onSubmit(async (values) => {
+ const name = { en: values.nameEn, am: values.nameAm };
+ try {
+ if (editing) {
+ await updateRank({
+ id: editing.id,
+ departmentId: values.departmentId,
+ certificateCategory: values.certificateCategory,
+ key: values.key,
+ name,
+ ladderOrder: values.ladderOrder,
+ }).unwrap();
+ notify.success(t("configuration.updated"));
+ } else {
+ await createRank({
+ departmentId: values.departmentId,
+ certificateCategory: values.certificateCategory,
+ key: values.key,
+ name,
+ ladderOrder: values.ladderOrder,
+ }).unwrap();
+ notify.success(t("configuration.created"));
+ }
+ resetForm();
+ form.reset();
+ } catch (e) {
+ handleError(e);
+ }
+ });
+
+ const confirmDelete = useCallback(async () => {
+ if (!deleteTarget) return;
+ try {
+ await deleteRank(deleteTarget.id).unwrap();
+ notify.success(t("configuration.deleted"));
+ closeDelete();
+ setDeleteTarget(null);
+ } catch (e) {
+ handleError(e);
+ }
+ }, [deleteTarget, deleteRank, closeDelete, handleError]);
+
+ const sortedRanks = [...ranks].sort(
+ (a, b) =>
+ a.departmentId.localeCompare(b.departmentId) ||
+ a.certificateCategory.localeCompare(b.certificateCategory) ||
+ a.ladderOrder - b.ladderOrder,
+ );
+
+ const columns: AdvancedColumn[] = [
+ { header: t("configuration.department"), cell: ({ row }) => deptName(row.original.departmentId) },
+ { header: t("configuration.category", "Ladder"), cell: ({ row }) => row.original.certificateCategory },
+ { header: t("configuration.rankOrder", "Rung"), cell: ({ row }) => row.original.ladderOrder },
+ { header: t("configuration.key", "Key"), cell: ({ row }) => row.original.key },
+ { header: t("configuration.name"), cell: ({ row }) => localized(row.original.name) },
+ {
+ header: "actions",
+ size: 90,
+ cell: ({ row }) => (
+
+
+
+
+ ),
+ },
+ ];
+
+ return (
+
+
+ {t("configuration.ranks", "Ranks")}
+ {!showForm && (
+ }
+ onClick={() => {
+ form.reset();
+ setShowForm(true);
+ }}
+ disabled={deptOptions.length === 0}
+ >
+ {t("configuration.addRank", "Add rank")}
+
+ )}
+
+
+ {}}
+ pageSize={sortedRanks.length || 10}
+ onPageSizeChange={() => {}}
+ refresh={refetch}
+ isLoading={isFetching}
+ />
+
+
+
+
+
+
+
+ {t("configuration.deleteConfirmText", { name: deleteTarget ? localized(deleteTarget.name) : "" })}
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/backoffice/src/app/features/configuration/pages/ConfigurationPage/index.tsx b/apps/backoffice/src/app/features/configuration/pages/ConfigurationPage/index.tsx
index 58fe49258..a28c98db2 100644
--- a/apps/backoffice/src/app/features/configuration/pages/ConfigurationPage/index.tsx
+++ b/apps/backoffice/src/app/features/configuration/pages/ConfigurationPage/index.tsx
@@ -23,6 +23,7 @@ import {
IconCertificate,
IconHash,
IconInfoCircle,
+ IconAnchor,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import {
@@ -36,6 +37,7 @@ import {
import { LocationPage } from "../../../location/pages/LocationPage";
import { CertificationPage } from "../../../certification/pages/CertificationPage";
import { NumberFormatTab } from "../../components/NumberFormatTab";
+import { RankDepartmentTab } from "./RankDepartmentTab";
import {
useGetOrganizationsQuery,
useGetProfessionsQuery,
@@ -401,6 +403,9 @@ export function ConfigurationPage() {
}>
{t("numberFormat.title", "Number Formats")}
+ }>
+ {t("configuration.ranksTab", "Ranks & Departments")}
+
@@ -418,6 +423,10 @@ export function ConfigurationPage() {
+
+
+
+
);
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.types.ts b/libs/api/src/lib/features/licensing/licensing.types.ts
index 22bef0df4..89e73ba1e 100644
--- a/libs/api/src/lib/features/licensing/licensing.types.ts
+++ b/libs/api/src/lib/features/licensing/licensing.types.ts
@@ -575,9 +575,37 @@ export interface TemplateFieldPlacement {
}
/** 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;