Add Rank and Department management to ConfigurationPage with new RankDepartmentTab

This commit is contained in:
Nati
2026-08-24 06:13:43 +00:00
parent e82714ba1f
commit f973bff641
4 changed files with 678 additions and 0 deletions

View File

@@ -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 <PageLoader label={t("configuration.loadingRanks", "Loading departments and ranks…")} height={300} />;
}
return (
<Stack gap="xl">
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
<Text size="sm">
{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.",
)}
</Text>
</Alert>
<DepartmentSection
departments={departments}
isFetching={deptFetching}
refetch={refetchDepts}
localized={localized}
/>
<RankSection
ranks={ranks}
deptOptions={deptOptions}
deptName={deptName}
isFetching={rankFetching}
refetch={refetchRanks}
localized={localized}
/>
</Stack>
);
}
// ------------------------------------------------------------- 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<Department | null>(null);
const [showForm, setShowForm] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Department | null>(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<Department>[] = [
{ 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 }) => (
<Group gap="xs">
<Button variant="subtle" size="xs" onClick={() => openEdit(row.original)}>
{t("configuration.edit", "Edit")}
</Button>
<Button
variant="subtle"
color="red"
size="xs"
onClick={() => {
setDeleteTarget(row.original);
openDelete();
}}
>
{t("configuration.delete")}
</Button>
</Group>
),
},
];
return (
<Stack gap="sm">
<Group justify="space-between" align="flex-end">
<Title order={4}>{t("configuration.departments", "Departments")}</Title>
{!showForm && (
<Button
variant="light"
size="sm"
leftSection={<IconPlus size={16} />}
onClick={() => {
form.reset();
setShowForm(true);
}}
>
{t("configuration.addDepartment", "Add department")}
</Button>
)}
</Group>
<AdvancedTable
columns={columns}
data={[...departments].sort((a, b) => a.sortOrder - b.sortOrder)}
tableName={t("configuration.departments", "Departments")}
itemCount={departments.length}
pageIndex={0}
onPageChange={() => {}}
pageSize={departments.length || 10}
onPageSizeChange={() => {}}
refresh={refetch}
isLoading={isFetching}
/>
<Modal
opened={showForm}
onClose={resetForm}
title={editing ? t("configuration.update") : t("configuration.addDepartment", "Add department")}
size="sm"
>
<form onSubmit={handleSubmit}>
<Stack gap="sm">
<TextInput
label={t("configuration.code", "Code")}
placeholder="DECK"
{...form.getInputProps("code")}
size="sm"
/>
<TextInput label={t("configuration.nameEn")} {...form.getInputProps("nameEn")} size="sm" />
<TextInput label={t("configuration.nameAm")} {...form.getInputProps("nameAm")} size="sm" />
<NumberInput
label={t("configuration.sortOrder", "Sort order")}
{...form.getInputProps("sortOrder")}
size="sm"
/>
<ModalFooter>
<Button variant="default" onClick={resetForm} size="sm">
{t("configuration.cancel")}
</Button>
<Button type="submit" size="sm" loading={isCreating || isUpdating}>
{editing ? t("configuration.update") : t("configuration.create")}
</Button>
</ModalFooter>
</Stack>
</form>
</Modal>
<Modal opened={deleteOpened} onClose={closeDelete} title={t("configuration.confirmDelete")} size="sm">
<Text mb="md">
{t("configuration.deleteConfirmText", { name: deleteTarget ? localized(deleteTarget.name) : "" })}
</Text>
<ModalFooter>
<Button variant="default" onClick={closeDelete} size="sm">
{t("configuration.cancel")}
</Button>
<Button color="red" onClick={confirmDelete} size="sm">
{t("configuration.delete")}
</Button>
</ModalFooter>
</Modal>
</Stack>
);
}
// ------------------------------------------------------------------ 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<Rank | null>(null);
const [showForm, setShowForm] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Rank | null>(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<Rank>[] = [
{ 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 }) => (
<Group gap="xs">
<Button variant="subtle" size="xs" onClick={() => openEdit(row.original)}>
{t("configuration.edit", "Edit")}
</Button>
<Button
variant="subtle"
color="red"
size="xs"
onClick={() => {
setDeleteTarget(row.original);
openDelete();
}}
>
{t("configuration.delete")}
</Button>
</Group>
),
},
];
return (
<Stack gap="sm">
<Group justify="space-between" align="flex-end">
<Title order={4}>{t("configuration.ranks", "Ranks")}</Title>
{!showForm && (
<Button
variant="light"
size="sm"
leftSection={<IconPlus size={16} />}
onClick={() => {
form.reset();
setShowForm(true);
}}
disabled={deptOptions.length === 0}
>
{t("configuration.addRank", "Add rank")}
</Button>
)}
</Group>
<AdvancedTable
columns={columns}
data={sortedRanks}
tableName={t("configuration.ranks", "Ranks")}
itemCount={sortedRanks.length}
pageIndex={0}
onPageChange={() => {}}
pageSize={sortedRanks.length || 10}
onPageSizeChange={() => {}}
refresh={refetch}
isLoading={isFetching}
/>
<Modal
opened={showForm}
onClose={resetForm}
title={editing ? t("configuration.update") : t("configuration.addRank", "Add rank")}
size="sm"
>
<form onSubmit={handleSubmit}>
<Stack gap="sm">
<Select
label={t("configuration.department")}
data={deptOptions}
{...form.getInputProps("departmentId")}
size="sm"
searchable
/>
<Select
label={t("configuration.category", "Ladder")}
data={CATEGORY_OPTIONS}
{...form.getInputProps("certificateCategory")}
size="sm"
allowDeselect={false}
/>
<TextInput
label={t("configuration.key", "Key")}
placeholder="CHIEF_MATE"
{...form.getInputProps("key")}
size="sm"
disabled={!!editing}
description={
editing
? t(
"configuration.keyLockedNotice",
"Not editable — issued licences already carry this key.",
)
: undefined
}
/>
<TextInput label={t("configuration.nameEn")} {...form.getInputProps("nameEn")} size="sm" />
<TextInput label={t("configuration.nameAm")} {...form.getInputProps("nameAm")} size="sm" />
<NumberInput
label={t("configuration.rankOrder", "Rung (0 = entry rank)")}
min={0}
{...form.getInputProps("ladderOrder")}
size="sm"
/>
<ModalFooter>
<Button variant="default" onClick={resetForm} size="sm">
{t("configuration.cancel")}
</Button>
<Button type="submit" size="sm" loading={isCreating || isUpdating}>
{editing ? t("configuration.update") : t("configuration.create")}
</Button>
</ModalFooter>
</Stack>
</form>
</Modal>
<Modal opened={deleteOpened} onClose={closeDelete} title={t("configuration.confirmDelete")} size="sm">
<Text mb="md">
{t("configuration.deleteConfirmText", { name: deleteTarget ? localized(deleteTarget.name) : "" })}
</Text>
<ModalFooter>
<Button variant="default" onClick={closeDelete} size="sm">
{t("configuration.cancel")}
</Button>
<Button color="red" onClick={confirmDelete} size="sm">
{t("configuration.delete")}
</Button>
</ModalFooter>
</Modal>
</Stack>
);
}

View File

@@ -23,6 +23,7 @@ import {
IconCertificate, IconCertificate,
IconHash, IconHash,
IconInfoCircle, IconInfoCircle,
IconAnchor,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
@@ -36,6 +37,7 @@ import {
import { LocationPage } from "../../../location/pages/LocationPage"; import { LocationPage } from "../../../location/pages/LocationPage";
import { CertificationPage } from "../../../certification/pages/CertificationPage"; import { CertificationPage } from "../../../certification/pages/CertificationPage";
import { NumberFormatTab } from "../../components/NumberFormatTab"; import { NumberFormatTab } from "../../components/NumberFormatTab";
import { RankDepartmentTab } from "./RankDepartmentTab";
import { import {
useGetOrganizationsQuery, useGetOrganizationsQuery,
useGetProfessionsQuery, useGetProfessionsQuery,
@@ -401,6 +403,9 @@ export function ConfigurationPage() {
<Tabs.Tab value="numberFormats" leftSection={<IconHash size={16} />}> <Tabs.Tab value="numberFormats" leftSection={<IconHash size={16} />}>
{t("numberFormat.title", "Number Formats")} {t("numberFormat.title", "Number Formats")}
</Tabs.Tab> </Tabs.Tab>
<Tabs.Tab value="ranks" leftSection={<IconAnchor size={16} />}>
{t("configuration.ranksTab", "Ranks & Departments")}
</Tabs.Tab>
</Tabs.List> </Tabs.List>
<Tabs.Panel value="professions" pt="md"> <Tabs.Panel value="professions" pt="md">
@@ -418,6 +423,10 @@ export function ConfigurationPage() {
<Tabs.Panel value="numberFormats" pt="md"> <Tabs.Panel value="numberFormats" pt="md">
<NumberFormatTab /> <NumberFormatTab />
</Tabs.Panel> </Tabs.Panel>
<Tabs.Panel value="ranks" pt="md">
<RankDepartmentTab />
</Tabs.Panel>
</Tabs> </Tabs>
</Stack> </Stack>
); );

View File

@@ -6,6 +6,7 @@ import type {
ApplicationPayment, ApplicationPayment,
ApplicationStaff, ApplicationStaff,
Attachment, Attachment,
Department,
DocumentRequirement, DocumentRequirement,
FormSchemaPalette, FormSchemaPalette,
FormSectionConfig, FormSectionConfig,
@@ -27,6 +28,8 @@ import type {
Paginated, Paginated,
QueueCounts, QueueCounts,
QueueFilter, QueueFilter,
Rank,
RankCertificateCategory,
RemarkTargetType, RemarkTargetType,
SavedQueueView, SavedQueueView,
SchemaIssue, SchemaIssue,
@@ -72,6 +75,8 @@ const TAGS = [
'SavedView', 'SavedView',
'LicenseTemplate', 'LicenseTemplate',
'DocumentRequirement', 'DocumentRequirement',
'Department',
'Rank',
] as const; ] as const;
const listTag = (type: (typeof TAGS)[number]) => ({ type, id: 'LIST' }) 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')]), invalidatesTags: (_r, error) => (error ? [] : [listTag('DocumentRequirement')]),
}), }),
// --------------------------------------------------- departments & ranks
/** Every department, for the admin editor. */
getDepartments: builder.query<Paginated<Department>, void>({
query: () => ({ url: '/departments' }),
providesTags: () => [listTag('Department')],
}),
/** Active departments only — the applicant-facing picker. */
getActiveDepartments: builder.query<Department[], void>({
query: () => ({ url: '/departments/active/list' }),
providesTags: () => [listTag('Department')],
}),
createDepartment: builder.mutation<
Department,
Partial<Department> & { code: string; name: Department['name'] }
>({
query: (body) => ({ url: '/departments', method: 'POST', body }),
invalidatesTags: (_r, error) => (error ? [] : [listTag('Department')]),
}),
updateDepartment: builder.mutation<Department, { id: string } & Partial<Department>>({
query: ({ id, ...body }) => ({ url: `/departments/${id}`, method: 'PUT', body }),
invalidatesTags: (_r, error) => (error ? [] : [listTag('Department')]),
}),
deleteDepartment: builder.mutation<unknown, string>({
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<Paginated<Rank>, 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<Rank> & {
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<Rank, { id: string } & Partial<Rank>>({
query: ({ id, ...body }) => ({ url: `/ranks/${id}`, method: 'PUT', body }),
invalidatesTags: (_r, error) => (error ? [] : [listTag('Rank')]),
}),
deleteRank: builder.mutation<unknown, string>({
query: (id) => ({ url: `/ranks/${id}`, method: 'DELETE' }),
invalidatesTags: (_r, error) => (error ? [] : [listTag('Rank')]),
}),
// -------------------------------------------------------- application // -------------------------------------------------------- application
createApplication: builder.mutation< createApplication: builder.mutation<
LicenseApplication, LicenseApplication,
@@ -641,6 +715,8 @@ export const licensingApi = baseApi
LicenseTemplate, LicenseTemplate,
{ {
licenseTypeId: string; licenseTypeId: string;
/** Scopes the draft to one rank's certificate. Omit for the type's default design. */
rankId?: string | null;
name: string; name: string;
hbsSource?: string; hbsSource?: string;
pageOptions?: TemplatePageOptions; pageOptions?: TemplatePageOptions;
@@ -654,6 +730,7 @@ export const licensingApi = baseApi
LicenseTemplate, LicenseTemplate,
{ {
id: string; id: string;
rankId?: string | null;
name?: string; name?: string;
hbsSource?: string; hbsSource?: string;
pageOptions?: TemplatePageOptions; pageOptions?: TemplatePageOptions;
@@ -919,6 +996,16 @@ export const {
useCreateDocumentRequirementMutation, useCreateDocumentRequirementMutation,
useUpdateDocumentRequirementMutation, useUpdateDocumentRequirementMutation,
useDeleteDocumentRequirementMutation, useDeleteDocumentRequirementMutation,
useGetDepartmentsQuery,
useGetActiveDepartmentsQuery,
useCreateDepartmentMutation,
useUpdateDepartmentMutation,
useDeleteDepartmentMutation,
useGetRanksQuery,
useGetRankLadderQuery,
useCreateRankMutation,
useUpdateRankMutation,
useDeleteRankMutation,
useUpdateLicenseValidityMutation, useUpdateLicenseValidityMutation,
useGetLicenseTypeRequirementsQuery, useGetLicenseTypeRequirementsQuery,
useCreateApplicationMutation, useCreateApplicationMutation,

View File

@@ -575,9 +575,37 @@ export interface TemplateFieldPlacement {
} }
/** A certificate design authored in the backoffice. */ /** 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 { export interface LicenseTemplate {
id: string; id: string;
licenseTypeId: string; licenseTypeId: string;
/** Scopes this design to one rank's certificate. Null = the type's default. */
rankId?: string | null;
name: string; name: string;
version: number; version: number;
hbsSource: string; hbsSource: string;