mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 13:02:50 +00:00
Add Rank and Department management to ConfigurationPage with new RankDepartmentTab
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
<Tabs.Tab value="numberFormats" leftSection={<IconHash size={16} />}>
|
||||
{t("numberFormat.title", "Number Formats")}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="ranks" leftSection={<IconAnchor size={16} />}>
|
||||
{t("configuration.ranksTab", "Ranks & Departments")}
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="professions" pt="md">
|
||||
@@ -418,6 +423,10 @@ export function ConfigurationPage() {
|
||||
<Tabs.Panel value="numberFormats" pt="md">
|
||||
<NumberFormatTab />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="ranks" pt="md">
|
||||
<RankDepartmentTab />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -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<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
|
||||
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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user