mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12: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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user