mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 13:02:50 +00:00
555 lines
17 KiB
TypeScript
555 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|