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,
|
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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user