diff --git a/apps/backoffice/src/app/features/configuration/pages/ConfigurationPage/RankDepartmentTab.tsx b/apps/backoffice/src/app/features/configuration/pages/ConfigurationPage/RankDepartmentTab.tsx new file mode 100644 index 000000000..f81c4a303 --- /dev/null +++ b/apps/backoffice/src/app/features/configuration/pages/ConfigurationPage/RankDepartmentTab.tsx @@ -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 ; + } + + return ( + + }> + + {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.", + )} + + + + + + + + ); +} + +// ------------------------------------------------------------- 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(null); + const [showForm, setShowForm] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(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[] = [ + { 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 }) => ( + + + + + ), + }, + ]; + + return ( + + + {t("configuration.departments", "Departments")} + {!showForm && ( + + )} + + + a.sortOrder - b.sortOrder)} + tableName={t("configuration.departments", "Departments")} + itemCount={departments.length} + pageIndex={0} + onPageChange={() => {}} + pageSize={departments.length || 10} + onPageSizeChange={() => {}} + refresh={refetch} + isLoading={isFetching} + /> + + +
+ + + + + + + + + + +
+
+ + + + {t("configuration.deleteConfirmText", { name: deleteTarget ? localized(deleteTarget.name) : "" })} + + + + + + +
+ ); +} + +// ------------------------------------------------------------------ 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(null); + const [showForm, setShowForm] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(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[] = [ + { 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 }) => ( + + + + + ), + }, + ]; + + return ( + + + {t("configuration.ranks", "Ranks")} + {!showForm && ( + + )} + + + {}} + pageSize={sortedRanks.length || 10} + onPageSizeChange={() => {}} + refresh={refetch} + isLoading={isFetching} + /> + + +
+ + + + + + + + + + + +
+
+ + + + {t("configuration.deleteConfirmText", { name: deleteTarget ? localized(deleteTarget.name) : "" })} + + + + + + +
+ ); +} diff --git a/apps/backoffice/src/app/features/configuration/pages/ConfigurationPage/index.tsx b/apps/backoffice/src/app/features/configuration/pages/ConfigurationPage/index.tsx index 58fe49258..a28c98db2 100644 --- a/apps/backoffice/src/app/features/configuration/pages/ConfigurationPage/index.tsx +++ b/apps/backoffice/src/app/features/configuration/pages/ConfigurationPage/index.tsx @@ -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() { }> {t("numberFormat.title", "Number Formats")} + }> + {t("configuration.ranksTab", "Ranks & Departments")} + @@ -418,6 +423,10 @@ export function ConfigurationPage() { + + + + ); diff --git a/libs/api/src/lib/features/licensing/licensing-api.ts b/libs/api/src/lib/features/licensing/licensing-api.ts index 6a960d0fa..3e17464ff 100644 --- a/libs/api/src/lib/features/licensing/licensing-api.ts +++ b/libs/api/src/lib/features/licensing/licensing-api.ts @@ -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, void>({ + query: () => ({ url: '/departments' }), + providesTags: () => [listTag('Department')], + }), + + /** Active departments only — the applicant-facing picker. */ + getActiveDepartments: builder.query({ + query: () => ({ url: '/departments/active/list' }), + providesTags: () => [listTag('Department')], + }), + + createDepartment: builder.mutation< + Department, + Partial & { code: string; name: Department['name'] } + >({ + query: (body) => ({ url: '/departments', method: 'POST', body }), + invalidatesTags: (_r, error) => (error ? [] : [listTag('Department')]), + }), + + updateDepartment: builder.mutation>({ + query: ({ id, ...body }) => ({ url: `/departments/${id}`, method: 'PUT', body }), + invalidatesTags: (_r, error) => (error ? [] : [listTag('Department')]), + }), + + deleteDepartment: builder.mutation({ + 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, 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 & { + 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>({ + query: ({ id, ...body }) => ({ url: `/ranks/${id}`, method: 'PUT', body }), + invalidatesTags: (_r, error) => (error ? [] : [listTag('Rank')]), + }), + + deleteRank: builder.mutation({ + 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, diff --git a/libs/api/src/lib/features/licensing/licensing.types.ts b/libs/api/src/lib/features/licensing/licensing.types.ts index 22bef0df4..89e73ba1e 100644 --- a/libs/api/src/lib/features/licensing/licensing.types.ts +++ b/libs/api/src/lib/features/licensing/licensing.types.ts @@ -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;