From 063a8799f51ef070c2048e509171e12f293f9e38 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 23 Jul 2026 13:41:39 +0000 Subject: [PATCH 1/6] fix(backoffice): make position type optional for unit departments Creating a department under a unit required picking a position type, which is not always known at that point. Sub-department creation is unchanged and still requires one. PositionPayload.positionTypeId becomes optional and the field is omitted from the request body when unset rather than sent as an empty string. Co-Authored-By: Claude Opus 4.8 --- .../services/api/positionService.ts | 2 +- .../userManagement/forms/AddDepartmentForm.tsx | 16 +++++----------- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/user-management/services/api/positionService.ts b/apps/edr-freight-web/backoffice/src/user-management/services/api/positionService.ts index 9e7e4c1d4..84782b38f 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/services/api/positionService.ts +++ b/apps/edr-freight-web/backoffice/src/user-management/services/api/positionService.ts @@ -21,7 +21,7 @@ export interface PositionPayload { organizationId: string; parentPositionId?: string; projectId?: string; - positionTypeId: string; + positionTypeId?: string; } export interface PositionQueryParams { orderBy?: string; diff --git a/apps/edr-freight-web/backoffice/src/user-management/userManagement/forms/AddDepartmentForm.tsx b/apps/edr-freight-web/backoffice/src/user-management/userManagement/forms/AddDepartmentForm.tsx index d16745327..872a82c9d 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/userManagement/forms/AddDepartmentForm.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/userManagement/forms/AddDepartmentForm.tsx @@ -48,8 +48,6 @@ export function AddDepartmentForm({ if (!nameAm.trim()) newErrors.nameAm = t("organization.amharicNameRequired"); if (!key.trim()) newErrors.key = t("contentManagement.keyRequired"); - if (!positionTypeId) - newErrors.positionTypeId = t("contentManagement.selectPosType"); setErrors(newErrors); return Object.keys(newErrors).length === 0; @@ -71,7 +69,7 @@ export function AddDepartmentForm({ key: key.trim().toLowerCase().replace(/\s+/g, "-"), unitId, organizationId, - positionTypeId, + ...(positionTypeId ? { positionTypeId } : {}), }; createPosition({ @@ -90,12 +88,11 @@ export function AddDepartmentForm({ return (
- + - {errors.positionTypeId && ( -

{errors.positionTypeId}

- )}
From 346d4718bf779aed0ed0647fdb20db88dfe7bdf8 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 23 Jul 2026 13:42:08 +0000 Subject: [PATCH 2/6] fix(backoffice): scope permission copying to the selected organization "Copy permissions from" listed every position type in every organization, because the dropdown read an unfiltered GET /position-types. IAM exposes no organization-scoped route and carries no organizationId on a position type, so the list is now narrowed client-side to the built-in (isSystem) types plus those whose unit belongs to the selected organization, with the type being edited excluded. Also in position management: - Invalidate every position-type cache key root after a mutation. React Query matches prefixes element by element, so ["position-type"] never reached ["position-types-common", ...] and the department pickers kept serving a stale list. invalidatePositionTypeQueries() covers all three roots and is shared by the hook and the form. - Drop getByOrganizationId and getCommonTypesByOrganizationId. Both issued the same requests as their unit counterparts and had no callers. - Surface errors that were being swallowed. Three mutations had empty onError handlers, hiding IAM's 403 for built-in position types, and CreatePositionForm's bare catch discarded the reason for every failure. - Move organization and unit into the zod schema so they validate with translated messages and inline errors instead of an ad-hoc toast, and keep submit disabled through the permission-assignment call that follows the save. - Report the two outcomes the form used to hide: a save that succeeded while permission assignment failed, and clearing every permission, which assign-seconds-for-first cannot express. - Fix the list page's loading and error states, which rendered the "Add User" string as a spinner, ignored the unit-scoped query, and left the export button stuck after a failed download. - Halve PermissionSearch's requests. It fetched 50 rows, read the total off the response and immediately refetched, and it re-filtered results on the undebounced term, blanking the list while typing. Remove the three record toggles. They never worked: IAM's PositionTypeConfiguration holds only { id, organizationId, positionTypeId, timeframe } in every published build, canAssignRecord and canCreateBankRecord exist nowhere in the package, and the global ValidationPipe runs with forbidNonWhitelisted, so every write was a 400. The reads were broken too, passing a positionTypeId to a route that filters on organizationId. A TODO records where the real flag lives: PositionConfiguration.canReceiveRecord, keyed by positionId. Delete ActionsColumn.tsx, which had no references. Co-Authored-By: Claude Opus 4.8 --- .../src/locales/am/translation.json | 13 + .../src/locales/en/translation.json | 13 + .../src/locales/fr/translation.json | 13 + .../position-management/ActionsColumn.tsx | 94 -- .../CreatePositionForm.tsx | 984 ++++++++++-------- .../position-management/EditPositionForm.tsx | 353 ++++--- .../position-management/PermissionSearch.tsx | 272 +++-- .../position-management/PositionLists.tsx | 529 +++++----- .../PositionTypeActions.tsx | 644 +++++------- .../PositionTypeColumnDefn.tsx | 9 - .../dto/positions/positionType.ts | 12 +- .../user-management/hooks/usePositionTypes.ts | 105 +- .../services/api/positionTypesService.ts | 31 +- 13 files changed, 1444 insertions(+), 1628 deletions(-) delete mode 100644 apps/edr-freight-web/backoffice/src/user-management/components/position-management/ActionsColumn.tsx diff --git a/apps/edr-freight-web/backoffice/src/locales/am/translation.json b/apps/edr-freight-web/backoffice/src/locales/am/translation.json index dcf029204..99c1e0329 100644 --- a/apps/edr-freight-web/backoffice/src/locales/am/translation.json +++ b/apps/edr-freight-web/backoffice/src/locales/am/translation.json @@ -2039,6 +2039,7 @@ "setting": "ቅንብሮች", "loadingAdmins": "አስተዳዳሪዎችን በመጫን ላይ...", "errorLoadingAdmins": "የአስተዳዳሪ መረጃን ማጫን ላይ ስህተት ተፈጥሯል", + "errorLoadingUnits": "ክፍሎችን ማጫን ላይ ስህተት ተፈጥሯል", "retry": "ደግመው ይሞክሩ", "assignAdmin": "አስተዳዳሪ መመደብ", "addAdmin": "አስተዳዳሪ ያክሉ", @@ -2651,6 +2652,18 @@ "selectApplicationToLoadPermissions": "ፍቃዶቹን ለማስገንዘብ አፕሊኬሽኑን ይምረጡ", "copyPermissionsHint": "የነበረ የቦታ አይነት ይምረጡ፤ ፍቃዶቹ አስቀድመው ይሞላሉ፣ ከታች ማስተካከል ይችላሉ።", "copyPermissionsFailed": "ፍቃዶችን መቅዳት አልተቻለም", + "selectOrganizationToCopy": "መቅዳት የሚችሏቸውን የቦታ ዓይነቶች ለማየት መጀመሪያ ድርጅት ይምረጡ", + "cannotClearAllPermissions": "ተቀምጧል። ፍቃዶቹ አልተቀየሩም — ይህ የቦታ ዓይነት ቢያንስ አንድ ፍቃድ ሊኖረው ይገባል።", + "permissionsSelected": "{{count}} ተመርጠዋል", + "positionTypeCreated": "የቦታ ዓይነት ተፈጥሯል", + "positionTypeUpdated": "የቦታ ዓይነት ተሻሽሏል", + "positionTypeDeleted": "የቦታ ዓይነት ተሰርዟል", + "positionTypeMigrated": "የቦታ ዓይነት ዝውውር ተሻሽሏል", + "positionTypeNotFound": "የቦታ ዓይነት አልተገኘም", + "permissionsAssignFailed": "የቦታ ዓይነቱ ተቀምጧል፣ ነገር ግን ፍቃዶቹን መመደብ አልተቻለም። እንደገና ለመሞከር ደግመው ይክፈቱት።", + "failedToLoadPermissions": "ፍቃዶችን መጫን አልተቻለም", + "failedToLoadPositionTypes": "የቦታ ዓይነቶችን መጫን አልተቻለም", + "exportFailed": "የቦታ ዓይነት ቁልፎችን መላክ አልተቻለም", "perFailed": "ፍቃድ መፍጠር አልተቻለም", "perSuccess": "የፍቃድ አይነት ተፈጠረና ፍቃዶች ተመደቡ", "updatePerSuccess": "ፍቃድ በትክክል ተዘምኗል", diff --git a/apps/edr-freight-web/backoffice/src/locales/en/translation.json b/apps/edr-freight-web/backoffice/src/locales/en/translation.json index 81bf4d519..f1357c2bf 100644 --- a/apps/edr-freight-web/backoffice/src/locales/en/translation.json +++ b/apps/edr-freight-web/backoffice/src/locales/en/translation.json @@ -2057,6 +2057,7 @@ "setting": "Setting", "loadingAdmins": "Loading admins...", "errorLoadingAdmins": "Error loading admin data", + "errorLoadingUnits": "Error loading units", "retry": "Retry", "assignAdmin": "Assign Admin", "addAdmin": "Add Admin", @@ -2760,6 +2761,18 @@ "selectApplicationToLoadPermissions": "Select an application to load its permissions", "copyPermissionsHint": "Pick an existing position type to pre-fill its permissions, then edit below.", "copyPermissionsFailed": "Failed to copy permissions", + "selectOrganizationToCopy": "Select an organization to see the position types you can copy from", + "cannotClearAllPermissions": "Saved. Permissions were left unchanged — this position type must keep at least one permission.", + "permissionsSelected": "{{count}} selected", + "positionTypeCreated": "Position type created", + "positionTypeUpdated": "Position type updated", + "positionTypeDeleted": "Position type deleted", + "positionTypeMigrated": "Position type migration updated", + "positionTypeNotFound": "Position type not found", + "permissionsAssignFailed": "Position type saved, but assigning its permissions failed. Reopen it to try again.", + "failedToLoadPermissions": "Failed to load permissions", + "failedToLoadPositionTypes": "Failed to load position types", + "exportFailed": "Failed to export position type keys", "perFailed": "Failed To Create Permission", "perSuccess": "Permission type created and permissions assigned", "updatePerSuccess": "Permission updated successfully", diff --git a/apps/edr-freight-web/backoffice/src/locales/fr/translation.json b/apps/edr-freight-web/backoffice/src/locales/fr/translation.json index 7f3d4e679..bd5e17fae 100644 --- a/apps/edr-freight-web/backoffice/src/locales/fr/translation.json +++ b/apps/edr-freight-web/backoffice/src/locales/fr/translation.json @@ -1525,6 +1525,7 @@ "setting": "Paramètre", "loadingAdmins": "Chargement des administrateurs...", "errorLoadingAdmins": "Erreur lors du chargement des données administrateur", + "errorLoadingUnits": "Erreur lors du chargement des unités", "retry": "Réessayer", "assignAdmin": "Assigner un administrateur", "addAdmin": "Ajouter un administrateur", @@ -1886,6 +1887,18 @@ "selectApplicationToLoadPermissions": "Sélectionner une application pour charger ses autorisations", "copyPermissionsHint": "Choisissez un type de poste existant pour préremplir ses autorisations, puis modifiez ci-dessous.", "copyPermissionsFailed": "Échec de la copie des autorisations", + "selectOrganizationToCopy": "Sélectionnez une organisation pour voir les types de poste que vous pouvez copier", + "cannotClearAllPermissions": "Enregistré. Les autorisations n'ont pas été modifiées — ce type de poste doit conserver au moins une autorisation.", + "permissionsSelected": "{{count}} sélectionné(s)", + "positionTypeCreated": "Type de poste créé", + "positionTypeUpdated": "Type de poste mis à jour", + "positionTypeDeleted": "Type de poste supprimé", + "positionTypeMigrated": "Migration du type de poste mise à jour", + "positionTypeNotFound": "Type de poste introuvable", + "permissionsAssignFailed": "Type de poste enregistré, mais l'attribution de ses autorisations a échoué. Rouvrez-le pour réessayer.", + "failedToLoadPermissions": "Échec du chargement des autorisations", + "failedToLoadPositionTypes": "Échec du chargement des types de poste", + "exportFailed": "Échec de l'exportation des clés de type de poste", "perFailed": "Échec de la création de l’autorisation", "perSuccess": "Type d’autorisation créé et autorisations assignées", "updatePerSuccess": "Autorisation mise à jour avec succès", diff --git a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/ActionsColumn.tsx b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/ActionsColumn.tsx deleted file mode 100644 index e7613d2e3..000000000 --- a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/ActionsColumn.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import { useState } from "react"; -import { useNavigate } from "react-router-dom"; -import { Button } from "@/shared/common/ui/button"; -import { - AlertDialog, - AlertDialogTrigger, - AlertDialogContent, - AlertDialogHeader, - AlertDialogFooter, - AlertDialogTitle, - AlertDialogDescription, - AlertDialogCancel, - AlertDialogAction, -} from "@/shared/common/ui/alert-dialog"; -import { usePositionTypes } from "@/user-management/hooks/usePositionTypes"; -import { PositionTypeDto } from "@/user-management/dto/positions/positionType"; -import { t } from "i18next"; - -type ActionsColumnProps = { - row: PositionTypeDto; -}; - -const ActionsColumn: React.FC = ({ row }) => { - const navigate = useNavigate(); - const [openDialog, setOpenDialog] = useState(false); - const [deletingId, setDeletingId] = useState(null); - - const { deletePositionType } = usePositionTypes({ id: "" }); - - const handleDeleteClick = (id: string) => { - setDeletingId(id); - setOpenDialog(true); - }; - - const handleDeleteConfirm = async () => { - if (!deletingId) return; - await deletePositionType.mutateAsync(deletingId); - setOpenDialog(false); - setDeletingId(null); - }; - - return ( -
- - - - - - - - - - {t("contentManagement.delMsg")} - - {t("contentManagement.delMsg2")} - - - - { - setOpenDialog(false); - setDeletingId(null); - }} - > - {t("common.Cancel")} - - - {deletePositionType.isPending - ? t("organization.deleting") - : t("organization.delete")} - - - - -
- ); -}; - -export default ActionsColumn; diff --git a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/CreatePositionForm.tsx b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/CreatePositionForm.tsx index d525aa4f8..f8fc0cfdd 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/CreatePositionForm.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/CreatePositionForm.tsx @@ -1,455 +1,529 @@ -import { useForm } from "react-hook-form"; -import { z } from "zod"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { Input } from "@/shared/common/ui/input"; -import { Button } from "@/shared/common/ui/button"; -import { - Form, - FormField, - FormItem, - FormLabel, - FormControl, - FormMessage, -} from "@/shared/common/ui/form"; -import { toast } from "sonner"; -import { usePositionTypes } from "@/user-management/hooks/usePositionTypes"; -import { positionTypePermissionService } from "@/user-management/services/api/positionTypePermissionService"; -import { useNavigate } from "react-router-dom"; -import { t } from "i18next"; -import { useAuth } from "@/shared/context/AuthContext"; -import { useUnit } from "@/user-management/hooks/useUnit"; -import { useEffect, useMemo, useRef, useState } from "react"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/shared/common/ui/select"; -import { SingleSelect } from "@/shared/common/ui/single-select"; -import { UnitDto } from "@/user-management/dto/unit/unitDto"; -import { PositionTypeDto } from "@/user-management/dto/positions/positionType"; -import { useLocalizedName } from "@/shared/common/localizedName"; -import { useOrganizations } from "@/super-admin/hooks/useOrganizations"; -import { OrganizationDto } from "@/shared/dto/organization/organizationDto"; -import i18n from "@/i18n"; -import { PermissionSearch } from "./PermissionSearch"; -import { useApplications } from "@/user-management/hooks/useApplications"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; - -const formSchema = z.object({ - nameAm: z.string().min(2), - nameEn: z.string().min(2), - permissions: z.array(z.string()), -}); - -type FormValues = z.infer; - -export interface CreatePositionFormProps { - mode?: "create" | "edit"; - positionTypeId?: string; - initialValues?: { - nameAm: string; - nameEn: string; - unitId: string; - key?: string; - }; - onSuccess?: () => void; - onCancel?: () => void; -} - -export const CreatePositionForm = ({ - mode = "create", - positionTypeId, - initialValues, - onSuccess, - onCancel, -}: CreatePositionFormProps = {}) => { - const navigate = useNavigate(); - const { - createPositionType, - updatePositionType, - positionTypes, - isLoading: isLoadingPositionTypes, - } = usePositionTypes(); - const { user } = useAuth(); - const { getList, getById } = useUnit(); - const localizedName = useLocalizedName(); - const userOrganizationId = - user?.employee && user.employee.length > 0 - ? user.employee[0].organizationId - : undefined; - const [selectedOrganizationId, setSelectedOrganizationId] = useState( - userOrganizationId ?? "", - ); - const [selectedUnitId, setSelectedUnitId] = useState( - initialValues?.unitId ?? "", - ); - const [selectedApplicationId, setSelectedApplicationId] = - useState(""); - const [copyFromPositionId, setCopyFromPositionId] = useState(""); - const [isCopying, setIsCopying] = useState(false); - const [isLoadingEditData, setIsLoadingEditData] = useState(mode === "edit"); - const hasLoadedEditData = useRef(false); - const lang = i18n.language; - const { applications, isLoading: isLoadingApplications } = useApplications(); - const queryClient = useQueryClient(); - - const { organizationsResponse, isLoading: isLoadingOrgs } = useOrganizations( - "Org", - { take: 3000 }, - ); - - const { data: unitsResponse, isLoading: isLoadingUnits } = getList( - selectedOrganizationId, - { take: 3000, skip: 0 }, - ); - - const organizationOptions = useMemo( - () => - (organizationsResponse?.items ?? []).map((org: OrganizationDto) => ({ - value: org.id, - label: localizedName(org.name) || org.id, - })), - [organizationsResponse, localizedName], - ); - - const unitOptions = useMemo( - () => - (unitsResponse?.data?.items ?? []).map((unit: UnitDto) => ({ - value: unit.id, - label: localizedName(unit.name) || unit.id, - })), - [unitsResponse, localizedName], - ); - - const { - data: editUnitResponse, - isSuccess: isUnitSuccess, - isError: isUnitError, - } = getById(initialValues?.unitId ?? ""); - - const { - data: permissionsResponse, - isSuccess: isPermissionsSuccess, - isError: isPermissionsError, - } = useQuery({ - queryKey: ["position-type-permissions", positionTypeId], - queryFn: () => - positionTypePermissionService.getPermissionsByPositionTypeId( - positionTypeId!, - ), - enabled: mode === "edit" && !!positionTypeId, - }); - // Reset the selected unit when the organization changes so a unit from a - // different org can't be submitted by mistake. - useEffect(() => { - if (mode === "edit") return; - setSelectedUnitId(""); - }, [selectedOrganizationId, mode]); - - const form = useForm({ - resolver: zodResolver(formSchema), - defaultValues: { - nameAm: initialValues?.nameAm ?? "", - nameEn: initialValues?.nameEn ?? "", - permissions: [], - }, - }); - - useEffect(() => { - if (mode !== "edit" || !initialValues || !positionTypeId) return; - if (hasLoadedEditData.current) return; - - const isUnitDone = !initialValues.unitId || isUnitSuccess || isUnitError; - const isPermissionsDone = isPermissionsSuccess || isPermissionsError; - - if (isUnitDone && isPermissionsDone) { - hasLoadedEditData.current = true; - - const unit = editUnitResponse?.data; - if (unit) { - setSelectedOrganizationId(unit.organizationId); - setSelectedUnitId(unit.id); - } else if (initialValues.unitId) { - setSelectedUnitId(initialValues.unitId); - } - - const ids = permissionsResponse?.data?.items?.map((p) => p.id) ?? []; - form.reset({ - nameAm: initialValues.nameAm, - nameEn: initialValues.nameEn, - permissions: ids, - }); - - setIsLoadingEditData(false); - } - }, [ - mode, - initialValues, - positionTypeId, - isUnitSuccess, - isUnitError, - isPermissionsSuccess, - isPermissionsError, - editUnitResponse, - permissionsResponse, - form, - ]); - - const handlePermissionChange = (permissionId: string, checked: boolean) => { - const currentPermissions = form.getValues("permissions"); - if (checked) { - form.setValue("permissions", [...currentPermissions, permissionId]); - } else { - form.setValue( - "permissions", - currentPermissions.filter((id) => id !== permissionId), - ); - } - }; - - const handleCopyFrom = async (positionTypeId: string) => { - setCopyFromPositionId(positionTypeId); - if (!positionTypeId) { - form.setValue("permissions", []); - return; - } - setIsCopying(true); - try { - const response = - await positionTypePermissionService.getPermissionsByPositionTypeId( - positionTypeId, - ); - const ids = response.data.items?.map((p) => p.id) ?? []; - form.setValue("permissions", ids); - } catch { - toast.error(t("contentManagement.copyPermissionsFailed")); - } finally { - setIsCopying(false); - } - }; - - const onSubmit = async (values: FormValues) => { - try { - if (!selectedUnitId) { - toast.error(t("organization.selectUnit")); - return; - } - - const payload = { - name: { - am: values.nameAm, - en: values.nameEn, - }, - key: values.nameEn.toLowerCase().replace(/\s+/g, "-"), - unitId: selectedUnitId, - }; - - let targetId = positionTypeId; - - if (mode === "edit" && positionTypeId) { - await updatePositionType.mutateAsync({ - id: positionTypeId, - data: payload, - }); - } else { - const response = await createPositionType.mutateAsync(payload); - targetId = response.data.id; - } - - if (targetId && values.permissions.length > 0) { - await positionTypePermissionService.assignPermissionsToPositionType({ - firstId: targetId, - secondIds: values.permissions, - }); - } - - queryClient.invalidateQueries({ - queryKey: ["position-type"], - }); - queryClient.invalidateQueries({ queryKey: ["position-types"] }); - queryClient.invalidateQueries({ - queryKey: ["position-type-permissions"], - }); - toast.success(t("contentManagement.permissionSuccess")); - - if (onSuccess) { - onSuccess(); - } else { - navigate("/user-management/position-management"); - } - } catch { - toast.error(t("contentManagement.permissionFailed")); - } - }; - - if (isLoadingEditData) { - return ( -
- {t("common.loading")} -
- ); - } - - return ( - - - ( - - {t("contentManagement.englishName")} - - - - - - )} - /> - - ( - - {t("contentManagement.amharicName")} - - - - - - )} - /> - - {/* ✅ Organization (searchable, all orgs) */} -
- - -
- - {/* ✅ Unit Selector — searchable, scoped to picked org */} -
- - -
- -
- - -
- -
- - -

- {t("contentManagement.copyPermissionsHint")} -

-
- - ( - - {t("contentManagement.permission")} - - - - )} - /> - -
- - -
- - - ); -}; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Input } from "@/shared/common/ui/input"; +import { Button } from "@/shared/common/ui/button"; +import { + Form, + FormField, + FormItem, + FormLabel, + FormControl, + FormMessage, +} from "@/shared/common/ui/form"; +import { toast } from "sonner"; +import { + invalidatePositionTypeQueries, + usePositionTypes, +} from "@/user-management/hooks/usePositionTypes"; +import { positionTypePermissionService } from "@/user-management/services/api/positionTypePermissionService"; +import { useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { useAuth } from "@/shared/context/AuthContext"; +import { useUnit } from "@/user-management/hooks/useUnit"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/common/ui/select"; +import { SingleSelect } from "@/shared/common/ui/single-select"; +import { UnitDto } from "@/user-management/dto/unit/unitDto"; +import { PositionTypeDto } from "@/user-management/dto/positions/positionType"; +import { useLocalizedName } from "@/shared/common/localizedName"; +import { useOrganizations } from "@/super-admin/hooks/useOrganizations"; +import { OrganizationDto } from "@/shared/dto/organization/organizationDto"; +import { useErrorHandler } from "@/shared/hooks/useErrorHandler"; +import { PermissionSearch } from "./PermissionSearch"; +import { useApplications } from "@/user-management/hooks/useApplications"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; + +export interface CreatePositionFormProps { + mode?: "create" | "edit"; + positionTypeId?: string; + initialValues?: { + nameAm: string; + nameEn: string; + unitId: string; + key?: string; + }; + onSuccess?: () => void; + onCancel?: () => void; +} + +export const CreatePositionForm = ({ + mode = "create", + positionTypeId, + initialValues, + onSuccess, + onCancel, +}: CreatePositionFormProps = {}) => { + const navigate = useNavigate(); + const { t } = useTranslation(); + const { handleError } = useErrorHandler(t); + const { + createPositionType, + updatePositionType, + positionTypes, + isLoading: isLoadingPositionTypes, + isError: isErrorPositionTypes, + } = usePositionTypes(); + const { user } = useAuth(); + const { getList, getById } = useUnit(); + const localizedName = useLocalizedName(); + const userOrganizationId = + user?.employee && user.employee.length > 0 + ? user.employee[0].organizationId + : undefined; + const [selectedApplicationId, setSelectedApplicationId] = + useState(""); + const [copyFromPositionId, setCopyFromPositionId] = useState(""); + const [isCopying, setIsCopying] = useState(false); + const [isLoadingEditData, setIsLoadingEditData] = useState(mode === "edit"); + const hasLoadedEditData = useRef(false); + // Permissions the position type had when the form opened. Needed because the + // API cannot represent "no permissions" (see onSubmit). + const loadedPermissionCount = useRef(0); + const { applications, isLoading: isLoadingApplications } = useApplications(); + const queryClient = useQueryClient(); + + const formSchema = useMemo( + () => + z.object({ + nameEn: z.string().trim().min(2, t("organization.englishNameRequired")), + nameAm: z.string().trim().min(2, t("organization.amharicNameRequired")), + organizationId: z.string().min(1, t("organization.organizationRequired")), + unitId: z.string().min(1, t("contentManagement.unitRequired")), + permissions: z.array(z.string()), + }), + [t], + ); + + type FormValues = z.infer; + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + nameAm: initialValues?.nameAm ?? "", + nameEn: initialValues?.nameEn ?? "", + organizationId: userOrganizationId ?? "", + unitId: initialValues?.unitId ?? "", + permissions: [], + }, + }); + + const selectedOrganizationId = form.watch("organizationId"); + + const { organizationsResponse, isLoading: isLoadingOrgs } = useOrganizations( + "Org", + { take: 3000 }, + ); + + const { data: unitsResponse, isLoading: isLoadingUnits } = getList( + selectedOrganizationId, + { take: 3000, skip: 0 }, + ); + + const organizationOptions = useMemo( + () => + (organizationsResponse?.items ?? []).map((org: OrganizationDto) => ({ + value: org.id, + label: localizedName(org.name) || org.id, + })), + [organizationsResponse, localizedName], + ); + + const unitOptions = useMemo( + () => + (unitsResponse?.data?.items ?? []).map((unit: UnitDto) => ({ + value: unit.id, + label: localizedName(unit.name) || unit.id, + })), + [unitsResponse, localizedName], + ); + + const { + data: editUnitResponse, + isSuccess: isUnitSuccess, + isError: isUnitError, + } = getById(initialValues?.unitId ?? ""); + + const { + data: permissionsResponse, + isSuccess: isPermissionsSuccess, + isError: isPermissionsError, + } = useQuery({ + queryKey: ["position-type-permissions", positionTypeId], + queryFn: () => + positionTypePermissionService.getPermissionsByPositionTypeId( + positionTypeId!, + ), + enabled: mode === "edit" && !!positionTypeId, + }); + + // A position type belongs to a unit, and a unit to an organization — IAM has + // no organizationId on the type itself and no organization-scoped route, so + // the picked org narrows the list through its units. isSystem types are the + // shared "commons" and stay available to every organization. + const orgUnitIds = useMemo( + () => + new Set( + (unitsResponse?.data?.items ?? []).map((unit: UnitDto) => unit.id), + ), + [unitsResponse], + ); + + const copyFromOptions = useMemo(() => { + if (!selectedOrganizationId) return []; + return positionTypes.filter( + (type: PositionTypeDto) => + type.id !== positionTypeId && + (type.isSystem || (!!type.unitId && orgUnitIds.has(type.unitId))), + ); + }, [positionTypes, orgUnitIds, selectedOrganizationId, positionTypeId]); + + // Reset the selected unit when the organization changes so a unit from a + // different org can't be submitted by mistake. The copy source is cleared + // too — it is scoped to the old organization. + useEffect(() => { + if (mode === "edit") return; + form.setValue("unitId", ""); + setCopyFromPositionId(""); + }, [selectedOrganizationId, mode, form]); + + useEffect(() => { + if (mode !== "edit" || !initialValues || !positionTypeId) return; + if (hasLoadedEditData.current) return; + + const isUnitDone = !initialValues.unitId || isUnitSuccess || isUnitError; + const isPermissionsDone = isPermissionsSuccess || isPermissionsError; + + if (isUnitDone && isPermissionsDone) { + hasLoadedEditData.current = true; + + const unit = editUnitResponse?.data; + const ids = permissionsResponse?.data?.items?.map((p) => p.id) ?? []; + loadedPermissionCount.current = ids.length; + + form.reset({ + nameAm: initialValues.nameAm, + nameEn: initialValues.nameEn, + organizationId: unit?.organizationId ?? userOrganizationId ?? "", + unitId: unit?.id ?? initialValues.unitId ?? "", + permissions: ids, + }); + + setIsLoadingEditData(false); + } + }, [ + mode, + initialValues, + positionTypeId, + isUnitSuccess, + isUnitError, + isPermissionsSuccess, + isPermissionsError, + editUnitResponse, + permissionsResponse, + userOrganizationId, + form, + ]); + + const handlePermissionChange = (permissionId: string, checked: boolean) => { + const currentPermissions = form.getValues("permissions"); + form.setValue( + "permissions", + checked + ? [...currentPermissions, permissionId] + : currentPermissions.filter((id) => id !== permissionId), + ); + }; + + const handleCopyFrom = async (sourcePositionTypeId: string) => { + setCopyFromPositionId(sourcePositionTypeId); + setIsCopying(true); + try { + const response = + await positionTypePermissionService.getPermissionsByPositionTypeId( + sourcePositionTypeId, + ); + const ids = response.data.items?.map((p) => p.id) ?? []; + form.setValue("permissions", ids); + } catch (error) { + handleError(error); + toast.error(t("contentManagement.copyPermissionsFailed")); + } finally { + setIsCopying(false); + } + }; + + const onSubmit = async (values: FormValues) => { + const payload = { + name: { am: values.nameAm, en: values.nameEn }, + key: values.nameEn.toLowerCase().replace(/\s+/g, "-"), + unitId: values.unitId, + }; + + // Save the position type first. If this fails nothing else runs, and the + // mutation's own onError surfaces the reason (403 for built-in types, + // conflict on the globally-unique key, ...). + let targetId = positionTypeId; + try { + if (mode === "edit" && positionTypeId) { + await updatePositionType.mutateAsync({ + id: positionTypeId, + data: payload, + }); + } else { + const response = await createPositionType.mutateAsync(payload); + targetId = response.data.id; + } + } catch { + return; // already reported by the mutation's onError + } + + // assign-seconds-for-first replaces the whole set, but an empty secondIds + // fails server-side — so "unassign everything" is not expressible. Keep the + // save and tell the user their permissions were left alone. + const mustClearAll = + values.permissions.length === 0 && loadedPermissionCount.current > 0; + + if (targetId && values.permissions.length > 0) { + try { + await positionTypePermissionService.assignPermissionsToPositionType({ + firstId: targetId, + secondIds: values.permissions, + }); + } catch (error) { + handleError(error); + invalidatePositionTypeQueries(queryClient); + toast.error(t("contentManagement.permissionsAssignFailed")); + return; + } + } + + invalidatePositionTypeQueries(queryClient); + queryClient.invalidateQueries({ queryKey: ["position-type-permissions"] }); + + if (mustClearAll) { + toast.warning(t("contentManagement.cannotClearAllPermissions")); + } else { + toast.success(t("contentManagement.permissionSuccess")); + } + + if (onSuccess) { + onSuccess(); + } else { + navigate("/user-management/position-management"); + } + }; + + if (isLoadingEditData) { + return ( +
+ {t("common.loading")} +
+ ); + } + + const selectedPermissionCount = form.watch("permissions").length; + // form.formState.isSubmitting stays true for the whole async handler, so it + // also covers the permission-assignment call that follows the save. + const isBusy = form.formState.isSubmitting || isCopying; + + const copyFromPlaceholder = !selectedOrganizationId + ? t("contentManagement.selectOrganizationToCopy") + : isCopying || isLoadingPositionTypes || isLoadingUnits + ? t("common.loading") + : isErrorPositionTypes + ? t("contentManagement.failedToLoadPositionTypes") + : t("contentManagement.selectPositionToCopy"); + + return ( +
+ + ( + + {t("contentManagement.englishName")} + + + + + + )} + /> + + ( + + {t("contentManagement.amharicName")} + + + + + + )} + /> + + {/* Organization (searchable, all orgs) */} + ( + + {t("organization.organization")} + + + + )} + /> + + {/* Unit Selector — searchable, scoped to picked org */} + ( + + {t("organization.selectUnit")} + + + + )} + /> + +
+ + +
+ +
+ + +

+ {t("contentManagement.copyPermissionsHint")} +

+
+ + ( + + + {t("contentManagement.permission")} + {selectedPermissionCount > 0 && ( + + ( + {t("contentManagement.permissionsSelected", { + count: selectedPermissionCount, + })} + ) + + )} + + + + + )} + /> + +
+ + +
+ + + ); +}; diff --git a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/EditPositionForm.tsx b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/EditPositionForm.tsx index e69a6eedb..befe2f7d8 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/EditPositionForm.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/EditPositionForm.tsx @@ -1,160 +1,193 @@ -import { useEffect, useState } from "react"; -import { Button } from "@/shared/common/ui/button"; -import { Input } from "@/shared/common/ui/input"; -import { Label } from "@/shared/common/ui/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/shared/common/ui/select"; -import { useNavigate } from "react-router-dom"; - -import { useUnit } from "@/user-management/hooks/useUnit"; -import { useAuth } from "@/shared/context/AuthContext"; -import { positionTypePermissionService } from "@/user-management/services/api/positionTypePermissionService"; -import { usePositionTypes } from "@/user-management/hooks/usePositionTypes"; -import { useApplications } from "@/user-management/hooks/useApplications"; -import { PermissionSearch } from "./PermissionSearch"; -import { PermissionDto } from "@/user-management/dto/permissions/permissonDto"; -import { useLocalizedName } from "@/shared/common/localizedName"; -import { UnitDto } from "@/user-management/dto/unit/unitDto"; -import { t } from "i18next"; - -export const EditPositionForm = ({ id }: { id: string }) => { - const navigate = useNavigate(); - const { user } = useAuth(); - const { getList } = useUnit(); - const localizedName = useLocalizedName(); - - const { positionType, isLoadingSingle } = usePositionTypes({ id }); - - const organizationId = - user?.employee && user.employee.length > 0 - ? user.employee[0].organizationId - : undefined; - - const { applications, isLoading: isLoadingApplications } = useApplications(); - - const { data: unitsResponse } = getList(organizationId || "", { - take: 300, - skip: 0, - }); - - const [selectedApplicationId, setSelectedApplicationId] = - useState(""); - const [assignedPermissions, setAssignedPermissions] = useState< - PermissionDto[] - >([]); - const [isLoadingPermissions, setIsLoadingPermissions] = useState(false); - - useEffect(() => { - const load = async () => { - if (!positionType) return; - setIsLoadingPermissions(true); - try { - const assigned = - await positionTypePermissionService.getPermissionsByPositionTypeId( - positionType.id, - ); - setAssignedPermissions(assigned.data.items ?? []); - } finally { - setIsLoadingPermissions(false); - } - }; - load(); - }, [positionType]); - - if (isLoadingSingle) return

Loading...

; - if (!positionType) return null; - - const unit = unitsResponse?.data?.items?.find( - (u: UnitDto) => u.id === positionType.unitId, - ); - const unitName = unit ? unit.name.en || unit.name.am : positionType.unitId; - - return ( -
-
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - {selectedApplicationId ? ( - perm.id)} - onPermissionChange={() => { - // view-only mode in edit form - }} - applicationId={selectedApplicationId} - disabled - /> - ) : ( -
- {isLoadingPermissions ? ( -
Loading...
- ) : assignedPermissions.length === 0 ? ( -
- {t("contentManagement.noPermissionsAvailable")} -
- ) : ( -
    - {assignedPermissions.map((perm) => ( -
  • - {localizedName(perm.name)} -
  • - ))} -
- )} -
- )} -
- -
- -
-
- ); -}; +import { useState } from "react"; +import { Button } from "@/shared/common/ui/button"; +import { Input } from "@/shared/common/ui/input"; +import { Label } from "@/shared/common/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/common/ui/select"; +import { useNavigate } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; + +import { useUnit } from "@/user-management/hooks/useUnit"; +import { useAuth } from "@/shared/context/AuthContext"; +import { positionTypePermissionService } from "@/user-management/services/api/positionTypePermissionService"; +import { usePositionTypes } from "@/user-management/hooks/usePositionTypes"; +import { useApplications } from "@/user-management/hooks/useApplications"; +import { PermissionSearch } from "./PermissionSearch"; +import { useLocalizedName } from "@/shared/common/localizedName"; +import { UnitDto } from "@/user-management/dto/unit/unitDto"; +import { t } from "i18next"; + +export const EditPositionForm = ({ id }: { id: string }) => { + const navigate = useNavigate(); + const { user } = useAuth(); + const { getList } = useUnit(); + const localizedName = useLocalizedName(); + + const { positionType, isLoadingSingle, isErrorSingle } = usePositionTypes({ + id, + }); + + const organizationId = + user?.employee && user.employee.length > 0 + ? user.employee[0].organizationId + : undefined; + + const { applications, isLoading: isLoadingApplications } = useApplications(); + + const { data: unitsResponse } = getList(organizationId || "", { + take: 300, + skip: 0, + }); + + const [selectedApplicationId, setSelectedApplicationId] = + useState(""); + + // Shares the cache key CreatePositionForm writes under, so editing a position + // type's permissions refreshes this view too. + const { + data: assignedResponse, + isLoading: isLoadingPermissions, + isError: isErrorPermissions, + } = useQuery({ + queryKey: ["position-type-permissions", id], + queryFn: () => + positionTypePermissionService.getPermissionsByPositionTypeId(id), + enabled: !!id, + }); + + const assignedPermissions = assignedResponse?.data?.items ?? []; + + if (isLoadingSingle) { + return ( +

+ {t("common.loading")} +

+ ); + } + + if (isErrorSingle || !positionType) { + return ( +
+

+ {t("contentManagement.positionTypeNotFound")} +

+
+ +
+
+ ); + } + + const unit = unitsResponse?.data?.items?.find( + (u: UnitDto) => u.id === positionType.unitId, + ); + const unitName = unit ? unit.name.en || unit.name.am : positionType.unitId; + + return ( +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + {selectedApplicationId ? ( + perm.id)} + onPermissionChange={() => { + // view-only mode in edit form + }} + applicationId={selectedApplicationId} + disabled + /> + ) : ( +
+ {isLoadingPermissions ? ( +
+ {t("common.loading")} +
+ ) : isErrorPermissions ? ( +
+ {t("contentManagement.failedToLoadPermissions")} +
+ ) : assignedPermissions.length === 0 ? ( +
+ {t("contentManagement.noPermissionsAvailable")} +
+ ) : ( +
    + {assignedPermissions.map((perm) => ( +
  • + {localizedName(perm.name)} +
  • + ))} +
+ )} +
+ )} +
+ +
+ +
+
+ ); +}; diff --git a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PermissionSearch.tsx b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PermissionSearch.tsx index bfc645fc7..8d853a3b0 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PermissionSearch.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PermissionSearch.tsx @@ -1,152 +1,120 @@ -import React, { useState, useEffect, useMemo, useRef } from "react"; -import { Input } from "@/shared/common/ui/input"; -import { Checkbox } from "@/shared/common/ui/checkbox"; -import { usePermissionManager } from "@/user-management/hooks/usePermissionManager"; -import { PermissionDto } from "@/user-management/dto/permissions/permissonDto"; -import { useLocalizedName } from "@/shared/common/localizedName"; -import { t } from "i18next"; -import { Search, Loader2 } from "lucide-react"; - -interface PermissionSearchProps { - selectedPermissions: string[]; - onPermissionChange: (permissionId: string, checked: boolean) => void; - applicationId?: string; - disabled?: boolean; -} - -const INITIAL_TAKE = 50; // Initial number of items to fetch - -export const PermissionSearch: React.FC = ({ - selectedPermissions, - onPermissionChange, - applicationId, - disabled = false, -}) => { - const [searchTerm, setSearchTerm] = useState(""); - const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(""); - const [take, setTake] = useState(INITIAL_TAKE); // Start with 50 - const hasSetTotalCount = useRef(false); // Track if we've set the total count - - const scrollContainerRef = useRef(null); - - const localizedName = useLocalizedName(); - - /** ------------------ 1. Debounce Search ------------------ */ - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedSearchTerm(searchTerm); - setTake(INITIAL_TAKE); // Reset to 50 - hasSetTotalCount.current = false; // Reset the flag - }, 300); - - return () => clearTimeout(timer); - }, [searchTerm]); - - /** ------------------ 2. Fetch Permissions ------------------ */ - const { permissions, isPermissionsLoading } = usePermissionManager({ - params: applicationId - ? { - take, - skip: 0, // Always skip 0, we fetch everything at once - search: debouncedSearchTerm || undefined, - applicationId, - } - : undefined, - }); - - /** ------------------ 3. Update take to total count after first fetch ------------------ */ - useEffect(() => { - if ( - permissions?.count && - !hasSetTotalCount.current && - take !== permissions.count - ) { - hasSetTotalCount.current = true; - setTake(permissions.count); // Fetch all items - } - }, [permissions?.count, take]); - - /** ------------------ 4. Client-side Filtering (Optional) ------------------ */ - const filteredPermissions = useMemo(() => { - if (!permissions?.items?.length) return []; - if (!searchTerm.trim()) return permissions.items; - - return permissions.items.filter((perm: PermissionDto) => { - const name = localizedName(perm.name).toLowerCase(); - const key = perm.key.toLowerCase(); - const search = searchTerm.toLowerCase(); - return name.includes(search) || key.includes(search); - }); - }, [permissions?.items, searchTerm, localizedName]); - - /** ------------------ Render ------------------ */ - return ( -
- {/* Search Input */} -
- - setSearchTerm(e.target.value)} - className="pl-10" - /> -
- - {/* Permission List Container */} - {!applicationId ? ( -
- {t("contentManagement.selectApplicationToLoadPermissions") || - "Select an application to load permissions."} -
- ) : isPermissionsLoading ? ( -
- -
- ) : ( -
- {filteredPermissions.length === 0 ? ( -
- {searchTerm - ? t("contentManagement.noPermissionsFound") - : t("contentManagement.noPermissionsAvailable")} -
- ) : ( -
- {filteredPermissions.map((perm: PermissionDto) => ( -
- { - if (!disabled) onPermissionChange(perm.id, !!checked); - }} - /> - -
- ))} -
- )} -
- )} - - {/* Footer Info */} - {filteredPermissions.length > 0 && ( -
- {t("contentManagement.showingPermissions", { - count: filteredPermissions.length, - total: permissions?.count || 0, - })} -
- )} -
- ); -}; +import React, { useState, useEffect } from "react"; +import { Input } from "@/shared/common/ui/input"; +import { Checkbox } from "@/shared/common/ui/checkbox"; +import { usePermissionManager } from "@/user-management/hooks/usePermissionManager"; +import { PermissionDto } from "@/user-management/dto/permissions/permissonDto"; +import { useLocalizedName } from "@/shared/common/localizedName"; +import { t } from "i18next"; +import { Search, Loader2 } from "lucide-react"; + +interface PermissionSearchProps { + selectedPermissions: string[]; + onPermissionChange: (permissionId: string, checked: boolean) => void; + applicationId?: string; + disabled?: boolean; +} + +// One request per application. This used to fetch 50, read `count` off the +// response and immediately refetch with take = count — two round trips on every +// mount for the same list. +const TAKE = 1000; + +export const PermissionSearch: React.FC = ({ + selectedPermissions, + onPermissionChange, + applicationId, + disabled = false, +}) => { + const [searchTerm, setSearchTerm] = useState(""); + const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(""); + + const localizedName = useLocalizedName(); + + /** ------------------ 1. Debounce Search ------------------ */ + useEffect(() => { + const timer = setTimeout(() => setDebouncedSearchTerm(searchTerm), 300); + return () => clearTimeout(timer); + }, [searchTerm]); + + /** ------------------ 2. Fetch Permissions ------------------ */ + // The API does the filtering. Filtering the result again on the *undebounced* + // term used to blank the list for 300ms on every keystroke. + const { permissions, isPermissionsLoading } = usePermissionManager({ + params: applicationId + ? { + take: TAKE, + skip: 0, + search: debouncedSearchTerm || undefined, + applicationId, + } + : undefined, + }); + + const items = permissions?.items ?? []; + + /** ------------------ Render ------------------ */ + return ( +
+ {/* Search Input */} +
+ + setSearchTerm(e.target.value)} + className="pl-10" + /> +
+ + {/* Permission List Container */} + {!applicationId ? ( +
+ {t("contentManagement.selectApplicationToLoadPermissions")} +
+ ) : isPermissionsLoading ? ( +
+ +
+ ) : ( +
+ {items.length === 0 ? ( +
+ {debouncedSearchTerm + ? t("contentManagement.noPermissionsFound") + : t("contentManagement.noPermissionsAvailable")} +
+ ) : ( +
+ {items.map((perm: PermissionDto) => ( +
+ { + if (!disabled) onPermissionChange(perm.id, !!checked); + }} + /> + +
+ ))} +
+ )} +
+ )} + + {/* Footer Info */} + {items.length > 0 && ( +
+ {t("contentManagement.showingPermissions", { + count: items.length, + total: permissions?.count || 0, + })} +
+ )} +
+ ); +}; diff --git a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PositionLists.tsx b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PositionLists.tsx index d15d82799..142b97f83 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PositionLists.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PositionLists.tsx @@ -1,276 +1,253 @@ -import { useEffect, useState, useMemo } from "react"; -import { Button } from "@/shared/common/ui/button"; -import { AdvancedTable } from "@/shared/common/ui/table/AdvancedTable"; - -import { Link } from "react-router-dom"; -import { Plus } from "lucide-react"; -import { - Card, - CardContent, - CardHeader, - CardTitle, -} from "@/shared/common/ui/card"; -import { usePositionTypes } from "@/user-management/hooks/usePositionTypes"; -import { createPositionTypeColumns } from "./PositionTypeColumnDefn"; -import { positionTypeService } from "@/user-management/services/api/positionTypesService"; -import { t } from "i18next"; -import { useUnit } from "@/user-management/hooks/useUnit"; -import { useAuth } from "@/shared/context/AuthContext"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/shared/common/ui/select"; -import { UnitDto } from "@/user-management/dto/unit/unitDto"; -import { usePositionTypeConfiguration } from "@/user-management/hooks/usePostionType"; - -export default function PositionManagement() { - const [pageIndex, setPageIndex] = useState(0); - const pageSize = 10; - const [isExporting, setIsExporting] = useState(false); - const [searchTerm, setSearchTerm] = useState(""); - const { createConfiguration } = usePositionTypeConfiguration(); - - const { user } = useAuth(); - - const { getAccessibleList } = useUnit(); - - const organizationId = user?.employee?.[0]?.organizationId; - - const { data: unitsResponse } = getAccessibleList(organizationId ?? "", { - take: 300, - skip: 0, - }); - - // Add state for selected unitId - // Default: if super_admin => "All", otherwise wait for units - const [selectedUnitId, setSelectedUnitId] = useState("All"); - - useEffect(() => { - // If there’s no selectedUnitId yet, default to first unit (if any), otherwise keep "All" - if (!selectedUnitId) { - if (unitsResponse?.data?.items?.length) { - setSelectedUnitId(unitsResponse.data.items[0].id); - } else { - setSelectedUnitId("All"); - } - } - }, [unitsResponse, selectedUnitId]); - - // Reset to first page whenever the search term or unit changes so users - // land on the first page of matches instead of an empty later page. - useEffect(() => { - setPageIndex(0); - }, [searchTerm, selectedUnitId]); - - const handlePageChange = (newPage: number) => { - setPageIndex(newPage); - }; - const { - positionTypeResponse, - isLoading, - positionTypeByUnitId, - refetch, - refetchPosition, - } = usePositionTypes({ - params: { - take: 1000, - skip: 0, - orderBy: "updatedAt:DESC", - }, - unitId: selectedUnitId === "All" ? undefined : selectedUnitId, - }); - - // Fetch position types without unitId for migration options - const { - positionTypeResponse: globalPositionTypes, - refetch: refetchGlobalPositionTypes, - } = usePositionTypes({ - params: { - take: 1000, // Get all global position types - skip: 0, - orderBy: "updatedAt:DESC", - }, - unitId: undefined, // Explicitly fetch position types without unitId - }); - - // Create a combined refetch function for the onDelete callback - const handlePositionTypeDeleted = async () => { - await Promise.all([ - selectedUnitId === "All" ? refetch() : refetchPosition(), - refetchGlobalPositionTypes(), - ]); - }; - const handleToggle = async ( - positionTypeId: string, - checked: boolean, - field: "canReceiveRecord" | "canAssignRecord" | "canCreateBankRecord", - ) => { - if (!selectedUnitId || selectedUnitId === "All") return; - - await createConfiguration({ - positionTypeId, - timeframe: "yearly", - organizationId: organizationId!, - canReceiveRecord: field === "canReceiveRecord" ? checked : false, - canAssignRecord: field === "canAssignRecord" ? checked : false, - canCreateBankRecord: field === "canCreateBankRecord" ? checked : false, - }); - - await handlePositionTypeDeleted(); - }; - - // Create columns with positionTypeResponse - const columns = useMemo( - () => - createPositionTypeColumns( - selectedUnitId === "All" ? positionTypeResponse : positionTypeByUnitId, - globalPositionTypes, - handlePositionTypeDeleted, - handlePositionTypeDeleted, - handleToggle, // ← pass toggle handler - selectedUnitId === "All", // ← isGlobal: hide toggle when "All" - ), - [ - selectedUnitId, - positionTypeResponse, - positionTypeByUnitId, - globalPositionTypes, - ], - ); - const allItems = useMemo( - () => - (selectedUnitId === "All" - ? positionTypeResponse?.items - : positionTypeByUnitId?.items) || [], - [selectedUnitId, positionTypeResponse?.items, positionTypeByUnitId?.items], - ); - - const filteredItems = useMemo(() => { - const trimmed = searchTerm.trim().toLowerCase(); - if (!trimmed) return allItems; - return allItems.filter((item: any) => { - const en = (item?.name?.en || "").toLowerCase(); - const am = (item?.name?.am || "").toLowerCase(); - const key = (item?.key || "").toLowerCase(); - return ( - en.includes(trimmed) || am.includes(trimmed) || key.includes(trimmed) - ); - }); - }, [allItems, searchTerm]); - - const paginatedItems = useMemo(() => { - const start = pageIndex * pageSize; - return filteredItems.slice(start, start + pageSize); - }, [filteredItems, pageIndex, pageSize]); - - if (isLoading) { - return
{t("contentManagement.addUser")}
; - } - - const exportTypes = () => { - setIsExporting(true); - positionTypeService - .getAll({ - take: 3000, - }) - .then((allPositionKeys) => { - // Get the position type keys - const positionTypeKeys = allPositionKeys.data?.items?.map((p) => p.key); - - if (positionTypeKeys && positionTypeKeys.length > 0) { - // Convert the array of keys into a string, with each key on a new line - const fileContent = positionTypeKeys.join("\n"); - - // Create a Blob from the string content - const blob = new Blob([fileContent], { type: "text/plain" }); - - // Create a link element to trigger the download - const link = document.createElement("a"); - - // Create an object URL for the Blob - link.href = URL.createObjectURL(blob); - - // Set the download attribute with a file name - link.download = "position_keys.txt"; - - // Programmatically trigger a click on the link to start the download - link.click(); - - // Clean up by revoking the object URL - URL.revokeObjectURL(link.href); - } else { - console.error("No position type keys found."); - } - setIsExporting(false); - }); - }; - - return ( -
- - - - {t("contentManagement.permissionType")} - - - - {unitsResponse?.data?.items?.length > 0 && ( -
- - -
- )} - - - - - } - pageIndex={pageIndex} - onPageChange={handlePageChange} - nextFunction={() => handlePageChange(pageIndex + 1)} - prevFunction={() => handlePageChange(Math.max(pageIndex - 1, 0))} - /> - -
-
- ); -} +import { useEffect, useState, useMemo, useCallback } from "react"; +import { Button } from "@/shared/common/ui/button"; +import { AdvancedTable } from "@/shared/common/ui/table/AdvancedTable"; + +import { Link } from "react-router-dom"; +import { Plus } from "lucide-react"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/shared/common/ui/card"; +import { usePositionTypes } from "@/user-management/hooks/usePositionTypes"; +import { createPositionTypeColumns } from "./PositionTypeColumnDefn"; +import { positionTypeService } from "@/user-management/services/api/positionTypesService"; +import { t } from "i18next"; +import { toast } from "sonner"; +import { useUnit } from "@/user-management/hooks/useUnit"; +import { useAuth } from "@/shared/context/AuthContext"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/common/ui/select"; +import { UnitDto } from "@/user-management/dto/unit/unitDto"; +import { PositionTypeDto } from "@/user-management/dto/positions/positionType"; + +export default function PositionManagement() { + const [pageIndex, setPageIndex] = useState(0); + const pageSize = 10; + const [isExporting, setIsExporting] = useState(false); + const [searchTerm, setSearchTerm] = useState(""); + + const { user } = useAuth(); + + const { getAccessibleList } = useUnit(); + + const organizationId = user?.employee?.[0]?.organizationId; + + const { data: unitsResponse, isError: isUnitsError } = getAccessibleList( + organizationId ?? "", + { + take: 300, + skip: 0, + }, + ); + + // Add state for selected unitId + // Default: if super_admin => "All", otherwise wait for units + const [selectedUnitId, setSelectedUnitId] = useState("All"); + + useEffect(() => { + // If there’s no selectedUnitId yet, default to first unit (if any), otherwise keep "All" + if (!selectedUnitId) { + if (unitsResponse?.data?.items?.length) { + setSelectedUnitId(unitsResponse.data.items[0].id); + } else { + setSelectedUnitId("All"); + } + } + }, [unitsResponse, selectedUnitId]); + + // Reset to first page whenever the search term or unit changes so users + // land on the first page of matches instead of an empty later page. + useEffect(() => { + setPageIndex(0); + }, [searchTerm, selectedUnitId]); + + const handlePageChange = (newPage: number) => { + setPageIndex(newPage); + }; + + const showingAllUnits = selectedUnitId === "All"; + + const { + positionTypeResponse, + isLoading, + isError, + positionTypeByUnitId, + isLoadingPosition, + isErrorPosition, + refetch, + refetchPosition, + } = usePositionTypes({ + params: { + take: 1000, + skip: 0, + orderBy: "updatedAt:DESC", + }, + unitId: showingAllUnits ? undefined : selectedUnitId, + }); + + // Refresh whichever list is on screen. `positionTypeResponse` is the + // unscoped fetch, so it doubles as the migration-target source — no second + // usePositionTypes() call needed (its cache key ignores unitId, so a second + // call returned the very same query). + const handlePositionTypeChanged = useCallback(async () => { + await (showingAllUnits ? refetch() : refetchPosition()); + }, [showingAllUnits, refetch, refetchPosition]); + + const columns = useMemo( + () => + createPositionTypeColumns( + positionTypeResponse, + handlePositionTypeChanged, + handlePositionTypeChanged, + ), + [positionTypeResponse, handlePositionTypeChanged], + ); + + const allItems = useMemo( + () => + (showingAllUnits + ? positionTypeResponse?.items + : positionTypeByUnitId?.items) || [], + [showingAllUnits, positionTypeResponse?.items, positionTypeByUnitId?.items], + ); + + const filteredItems = useMemo(() => { + const trimmed = searchTerm.trim().toLowerCase(); + if (!trimmed) return allItems; + return allItems.filter((item: PositionTypeDto) => { + const en = (item?.name?.en || "").toLowerCase(); + const am = (item?.name?.am || "").toLowerCase(); + const key = (item?.key || "").toLowerCase(); + return ( + en.includes(trimmed) || am.includes(trimmed) || key.includes(trimmed) + ); + }); + }, [allItems, searchTerm]); + + const paginatedItems = useMemo(() => { + const start = pageIndex * pageSize; + return filteredItems.slice(start, start + pageSize); + }, [filteredItems, pageIndex, pageSize]); + + // Track whichever query is actually feeding the table — picking a unit used + // to leave the previous unit's rows on screen with no loading state. + const isLoadingList = showingAllUnits ? isLoading : isLoadingPosition; + const isErrorList = showingAllUnits ? isError : isErrorPosition; + + const exportTypes = () => { + setIsExporting(true); + positionTypeService + .getAll({ take: 3000 }) + .then((allPositionKeys) => { + const positionTypeKeys = allPositionKeys.data?.items?.map((p) => p.key); + + if (!positionTypeKeys?.length) { + toast.error(t("contentManagement.exportFailed")); + return; + } + + // One key per line, downloaded as a plain text file. + const blob = new Blob([positionTypeKeys.join("\n")], { + type: "text/plain", + }); + const link = document.createElement("a"); + link.href = URL.createObjectURL(blob); + link.download = "position_keys.txt"; + link.click(); + URL.revokeObjectURL(link.href); + }) + .catch(() => { + toast.error(t("contentManagement.exportFailed")); + }) + .finally(() => { + setIsExporting(false); + }); + }; + + return ( +
+ + + + {t("contentManagement.permissionType")} + + + + {!!unitsResponse?.data?.items?.length && ( +
+ + +
+ )} + {isUnitsError && ( +

+ {t("organization.errorLoadingUnits")} +

+ )} + + {isLoadingList ? ( +
+ {t("common.loading")} +
+ ) : isErrorList ? ( +
+ {t("contentManagement.failedToLoadPositionTypes")} +
+ ) : ( + + + + } + pageIndex={pageIndex} + onPageChange={handlePageChange} + nextFunction={() => handlePageChange(pageIndex + 1)} + prevFunction={() => handlePageChange(Math.max(pageIndex - 1, 0))} + /> + )} +
+
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PositionTypeActions.tsx b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PositionTypeActions.tsx index 32613a0ea..1a2b829d6 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PositionTypeActions.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PositionTypeActions.tsx @@ -1,393 +1,251 @@ -import { useState } from "react"; -import { useNavigate } from "react-router-dom"; -import { PositionTypeDto } from "@/user-management/dto/positions/positionType"; - -import { - DropdownMenu, - DropdownMenuTrigger, - DropdownMenuContent, - DropdownMenuLabel, - DropdownMenuItem, -} from "@/shared/common/ui/dropdown-menu"; - -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/shared/common/ui/alert-dialog"; - -import { Button } from "@/shared/common/ui/button"; -import { MoreVertical, Edit, Eye, Trash2, Pencil } from "lucide-react"; -import { t } from "i18next"; -import PositionTypeMigrationModal from "./PostionTypeMigration"; -import { CreatePositionForm } from "./CreatePositionForm"; -import { toast } from "sonner"; -import { useLocalizedName } from "@/shared/common/localizedName"; -import { positionTypeService } from "@/user-management/services/api/positionTypesService"; -import { useErrorHandler } from "@/shared/hooks/useErrorHandler"; -import { usePositionTypeConfiguration } from "@/user-management/hooks/usePostionType"; -import { Switch } from "@/shared/common/ui/switch"; -import { PositionTypeConfigurationDto } from "@/user-management/services/api/positionTypeConfigurationService"; -import { useQueryClient } from "@tanstack/react-query"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, -} from "@/shared/common/ui/dialog"; - -interface PositionTypeResponse { - items: PositionTypeDto[]; - count: number; -} - -type ActionsCellProps = { - row: PositionTypeDto | PositionTypeConfigurationDto; - globalPositionTypes?: PositionTypeResponse; - onDelete?: () => void | Promise; - onEdit?: () => void | Promise; - onToggle?: ( - positionTypeId: string, - checked: boolean, - field: "canReceiveRecord" | "canAssignRecord" | "canCreateBankRecord", - ) => void | Promise; - isGlobal?: boolean; // true when viewing "All" units — hide toggle -}; - -const PositionTypeActionsCell: React.FC = ({ - row, - globalPositionTypes, - onDelete, - onEdit, - onToggle, - isGlobal = false, -}) => { - const navigate = useNavigate(); - const [dropdownOpen, setDropdownOpen] = useState(false); - const [showMigrateDialog, setShowMigrateDialog] = useState(false); - const [showEditDialog, setShowEditDialog] = useState(false); - const [showDeleteDialog, setShowDeleteDialog] = useState(false); - const [isDeleting, setIsDeleting] = useState(false); - const localizedName = useLocalizedName(); - const { handleError } = useErrorHandler(t); - const queryClient = useQueryClient(); - // Use row.id as the positionTypeId for the configuration lookup - - const { - configurations, - isLoadingConfigurations, - updateConfiguration, - isUpdatingConfiguration, - } = usePositionTypeConfiguration( - row?.id ?? null, // 👈 pass row.id as unitId - ); - - const configItem = configurations[0]; - const isCanReceiveRecord = configItem?.canReceiveRecord ?? false; - const isCanAssignRecord = configItem?.canAssignRecord ?? false; - const isCanCreateBankRecord = configItem?.canCreateBankRecord ?? false; - - const invalidateConfig = () => { - queryClient.invalidateQueries({ - queryKey: ["positionTypeConfigurations", row.id], - }); - queryClient.invalidateQueries({ - queryKey: ["positionTypeConfiguration", row.id], - }); - }; - - // Create position type options from globalPositionTypes - only those WITHOUT unitId - const positionTypeOptions = - globalPositionTypes?.items - .filter((item) => !item.unitId) - .map((item) => ({ - label: localizedName(item.name), - value: item.id, - })) || []; - - // Only show migrate/delete actions if current row has a unitId - const canBeModified = !!row.unitId; - - const handleView = () => { - navigate(`/user-management/position-management/edit/${row.id}`); - }; - - const handleMigrate = (e: Event) => { - e.preventDefault(); - setDropdownOpen(false); - setShowMigrateDialog(true); - }; - - const handleEdit = (e: Event) => { - e.preventDefault(); - setDropdownOpen(false); - setShowEditDialog(true); - }; - - const handleDelete = async () => { - try { - setIsDeleting(true); - await positionTypeService.delete(row.id); - toast.success(t("common.DeletedSuccessfully")); - setShowDeleteDialog(false); - if (onDelete) { - await onDelete(); - } - } catch (error) { - handleError(error); - toast.error(t("common.FailedToDelete")); - } finally { - setIsDeleting(false); - } - }; - - const handleToggleChange = async (checked: boolean) => { - if (isGlobal) return; - try { - if (configItem?.id) { - await updateConfiguration({ - id: configItem.id, - payload: { - organizationId: configItem.organizationId, - positionTypeId: configItem.positionTypeId, - timeframe: configItem.timeframe, - canReceiveRecord: checked, - }, - }); - } else { - await onToggle?.(row.id, checked, "canReceiveRecord"); - } - toast.success(t("incomingRecord.UpdatedSuccessfully")); - invalidateConfig(); - } catch (error) { - handleError(error); - toast.error(t("incomingRecord.FailedToUpdate")); - } - }; - - const handleAssignToggleChange = async (checked: boolean) => { - if (isGlobal) return; - try { - if (configItem?.id) { - await updateConfiguration({ - id: configItem.id, - payload: { - organizationId: configItem.organizationId, - positionTypeId: configItem.positionTypeId, - timeframe: configItem.timeframe, - canAssignRecord: checked, - }, - }); - } else { - await onToggle?.(row.id, checked, "canAssignRecord"); - } - toast.success(t("incomingRecord.UpdatedSuccessfully")); - invalidateConfig(); - } catch (error) { - handleError(error); - toast.error(t("incomingRecord.FailedToUpdate")); - } - }; - - const handleCreateBankRecordToggleChange = async (checked: boolean) => { - if (isGlobal) return; - try { - if (configItem?.id) { - await updateConfiguration({ - id: configItem.id, - payload: { - organizationId: configItem.organizationId, - positionTypeId: configItem.positionTypeId, - timeframe: configItem.timeframe, - canCreateBankRecord: checked, - }, - }); - } else { - await onToggle?.(row.id, checked, "canCreateBankRecord"); - } - toast.success(t("incomingRecord.UpdatedSuccessfully")); - invalidateConfig(); - } catch (error) { - handleError(error); - toast.error(t("incomingRecord.FailedToUpdate")); - } - }; - const rowName = "name" in row ? row.name : { am: "", en: "" }; - const isPositionType = "name" in row && "key" in row; - - return ( - <> - - - - - - { - const target = e.target as HTMLElement; - if (!target.closest('[role="dialog"]')) { - setDropdownOpen(false); - } - }}> - Actions - - {canBeModified && ( - - - {t("common.Migrate")} - - )} - - {canBeModified && isPositionType && ( - - - {t("common.Edit")} - - )} - - - - {t("common.View")} - - - {canBeModified && ( - { - setDropdownOpen(false); - setShowDeleteDialog(true); - }} - className="cursor-pointer hover:!text-red-500 !bg-transparent !transition-colors duration-200"> - - {t("common.Delete")} - - )} - - {/* Toggle moved here from ToggleCell */} - {!isGlobal && ( -
-
- - {t("contentManagement.CanReceiveRecord")} - - -
-
- )} - {!isGlobal && ( -
-
- - {t("contentManagement.CanAssignRecord")} - - -
-
- )} - {!isGlobal && ( -
-
- - {t("contentManagement.CanCreateBankRecord")} - - -
-
- )} -
-
- - {showMigrateDialog && ( - { - setShowMigrateDialog(false); - }} - toId={row.id} - toName={localizedName(rowName)} - positionTypeOptions={positionTypeOptions} - /> - )} - - - - - {t("common.Edit")} - -
- {isPositionType && showEditDialog && ( - { - setShowEditDialog(false); - if (onEdit) { - await onEdit(); - } - }} - onCancel={() => setShowEditDialog(false)} - /> - )} -
-
-
- - - - - {t("common.ConfirmDelete")} - - {t("common.DeleteConfirmationMessage", { - defaultValue: `Are you sure you want to delete "${localizedName(rowName)}"? This action cannot be undone.`, - })} - - - - {t("common.Cancel")} - - {isDeleting ? t("common.Deleting") : t("common.Delete")} - - - - - - ); -}; - -export default PositionTypeActionsCell; +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { PositionTypeDto } from "@/user-management/dto/positions/positionType"; + +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuItem, +} from "@/shared/common/ui/dropdown-menu"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shared/common/ui/alert-dialog"; + +import { Button } from "@/shared/common/ui/button"; +import { MoreVertical, Edit, Eye, Trash2, Pencil } from "lucide-react"; +import { t } from "i18next"; +import PositionTypeMigrationModal from "./PostionTypeMigration"; +import { CreatePositionForm } from "./CreatePositionForm"; +import { useLocalizedName } from "@/shared/common/localizedName"; +import { usePositionTypes } from "@/user-management/hooks/usePositionTypes"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/shared/common/ui/dialog"; + +interface PositionTypeResponse { + items: PositionTypeDto[]; + count: number; +} + +type ActionsCellProps = { + row: PositionTypeDto; + globalPositionTypes?: PositionTypeResponse; + onDelete?: () => void | Promise; + onEdit?: () => void | Promise; +}; + +/* + * TODO(record-toggles): this menu used to carry CanReceiveRecord / + * CanAssignRecord / CanCreateBankRecord switches. They never worked. IAM's + * PositionTypeConfiguration entity only has { id, organizationId, + * positionTypeId, timeframe } — verified against every local build (0.7.4 + * through 0.7.12) and the live swagger. canAssignRecord and + * canCreateBankRecord do not exist anywhere in the IAM package, and the global + * ValidationPipe runs with forbidNonWhitelisted, so every write 400'd. The + * reads were broken too: the list route filters on organizationId (the repo is + * built as TExtraCrudRepository(repo, "organizationId")) while the UI passed a + * positionTypeId, so it always came back empty. + * + * The flag that does exist is PositionConfiguration.canReceiveRecord, keyed by + * positionId — a per-position setting served by /api/position-configurations, + * not a per-position-type one. Restoring this needs either that endpoint and a + * position-level UI, or new columns on PositionTypeConfiguration in IAM. + */ +const PositionTypeActionsCell: React.FC = ({ + row, + globalPositionTypes, + onDelete, + onEdit, +}) => { + const navigate = useNavigate(); + const [dropdownOpen, setDropdownOpen] = useState(false); + const [showMigrateDialog, setShowMigrateDialog] = useState(false); + const [showEditDialog, setShowEditDialog] = useState(false); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const localizedName = useLocalizedName(); + const { deletePositionType } = usePositionTypes(); + + // Create position type options from globalPositionTypes - only those WITHOUT unitId + const positionTypeOptions = + globalPositionTypes?.items + .filter((item) => !item.unitId) + .map((item) => ({ + label: localizedName(item.name), + value: item.id, + })) || []; + + // Only show migrate/delete actions if current row has a unitId + const canBeModified = !!row.unitId; + + const handleView = () => { + navigate(`/user-management/position-management/edit/${row.id}`); + }; + + const handleMigrate = (e: Event) => { + e.preventDefault(); + setDropdownOpen(false); + setShowMigrateDialog(true); + }; + + const handleEdit = (e: Event) => { + e.preventDefault(); + setDropdownOpen(false); + setShowEditDialog(true); + }; + + // Goes through the mutation rather than the service directly, so the cache is + // invalidated and IAM's 403 for built-in types reaches the user. + const handleDelete = async () => { + try { + await deletePositionType.mutateAsync(row.id); + setShowDeleteDialog(false); + await onDelete?.(); + } catch { + // reported by the mutation's onError + } + }; + + return ( + <> + + + + + + { + const target = e.target as HTMLElement; + if (!target.closest('[role="dialog"]')) { + setDropdownOpen(false); + } + }}> + {t("userRecord.Actions")} + + {canBeModified && ( + + + {t("common.Migrate")} + + )} + + {canBeModified && ( + + + {t("common.Edit")} + + )} + + + + {t("common.View")} + + + {canBeModified && ( + { + setDropdownOpen(false); + setShowDeleteDialog(true); + }} + className="cursor-pointer hover:!text-red-500 !bg-transparent !transition-colors duration-200"> + + {t("common.Delete")} + + )} + + + + {showMigrateDialog && ( + { + setShowMigrateDialog(false); + }} + toId={row.id} + toName={localizedName(row.name)} + positionTypeOptions={positionTypeOptions} + /> + )} + + + + + {t("common.Edit")} + +
+ {showEditDialog && ( + { + setShowEditDialog(false); + if (onEdit) { + await onEdit(); + } + }} + onCancel={() => setShowEditDialog(false)} + /> + )} +
+
+
+ + + + + {t("common.ConfirmDelete")} + + {t("common.DeleteConfirmationMessage", { + name: localizedName(row.name), + })} + + + + + {t("common.Cancel")} + + + {deletePositionType.isPending + ? t("common.Deleting") + : t("common.Delete")} + + + + + + ); +}; + +export default PositionTypeActionsCell; diff --git a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PositionTypeColumnDefn.tsx b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PositionTypeColumnDefn.tsx index d5fcb509e..bcd73ae3f 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PositionTypeColumnDefn.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PositionTypeColumnDefn.tsx @@ -16,16 +16,9 @@ const NameCell = ({ name }: { name: PositionTypeDto["name"] }) => { }; export const createPositionTypeColumns = ( - _positionTypeResponse?: PositionTypeResponse, globalPositionTypes?: PositionTypeResponse, onDelete?: () => void | Promise, onEdit?: () => void | Promise, - onToggle?: ( - positionTypeId: string, - checked: boolean, - field: "canReceiveRecord" | "canAssignRecord" | "canCreateBankRecord", - ) => void | Promise, - isGlobal?: boolean, ): ColumnDef[] => [ { accessorKey: "name", @@ -64,8 +57,6 @@ export const createPositionTypeColumns = ( globalPositionTypes={globalPositionTypes} onDelete={onDelete} onEdit={onEdit} - onToggle={onToggle} - isGlobal={isGlobal} /> ), }, diff --git a/apps/edr-freight-web/backoffice/src/user-management/dto/positions/positionType.ts b/apps/edr-freight-web/backoffice/src/user-management/dto/positions/positionType.ts index 3f76be9f7..c2e79844f 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/dto/positions/positionType.ts +++ b/apps/edr-freight-web/backoffice/src/user-management/dto/positions/positionType.ts @@ -5,10 +5,14 @@ export interface PositionTypeDto { en: string; }; key: string; - unitId: string; - canReceiveRecord: boolean; - canCreateBankRecord?: boolean; - canAssignRecord: boolean; + /** + * Null for the built-in ("common") types, which `isSystem` marks and which + * every unit can use. IAM has no organizationId on a position type — the + * owning organization is only reachable via unit -> organizationId. + */ + unitId: string | null; + /** Built-in type. IAM rejects update/delete on these with a 403. */ + isSystem?: boolean; createdAt: string; updatedAt: string; } diff --git a/apps/edr-freight-web/backoffice/src/user-management/hooks/usePositionTypes.ts b/apps/edr-freight-web/backoffice/src/user-management/hooks/usePositionTypes.ts index a62ef26d6..8f102532d 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/hooks/usePositionTypes.ts +++ b/apps/edr-freight-web/backoffice/src/user-management/hooks/usePositionTypes.ts @@ -1,4 +1,9 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + QueryClient, + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; import { CreatePositionTypePayload, PositionRequest, @@ -23,10 +28,22 @@ interface positionParams { interface UsePositionTypeManagerProps { id?: string; unitId?: string; - organizationId?: string; params?: positionParams; // 👈 we expected query params to be passed like this } +/** + * Every cache key this hook writes under. React Query matches key prefixes + * element by element, so `["position-type"]` does NOT reach + * `["position-types-common", ...]` — each root has to be listed. Anything that + * mutates a position type should call this rather than hand-picking keys, or + * the department pickers (which read the "-common" queries) go stale. + */ +export const invalidatePositionTypeQueries = (queryClient: QueryClient) => { + ["position-types", "position-type", "position-types-common"].forEach( + (root) => queryClient.invalidateQueries({ queryKey: [root] }), + ); +}; + export const usePositionTypes = ({ id, params = { @@ -35,11 +52,11 @@ export const usePositionTypes = ({ orderBy: "createdAt:Desc", }, unitId, - organizationId, }: UsePositionTypeManagerProps = {}) => { const queryClient = useQueryClient(); const { t } = useTranslation(); const { handleError } = useErrorHandler(t); + const invalidateAll = () => invalidatePositionTypeQueries(queryClient); const { data, isLoading, isError, refetch } = useQuery({ queryKey: ["position-types", params], queryFn: () => positionTypeService.getAll(params).then((res) => res.data), @@ -70,38 +87,6 @@ export const usePositionTypes = ({ enabled: !!unitId, }); - // Position types by organization ID - const { - data: positionTypeByOrgId, - isLoading: isLoadingOrgPosition, - isError: isErrorOrgPosition, - refetch: refetchOrgPosition, - } = useQuery({ - queryKey: ["position-type-org", organizationId, params], - queryFn: async () => { - if (!organizationId) return undefined; - const res = await positionTypeService.getByOrganizationId(organizationId, params); - return res.data as PositionTypesListResponse | undefined; - }, - enabled: !!organizationId, - }); - - // Common types with organization ID (includes both org-specific and common types) - const { - data: commonPositionTypesByOrgId, - isLoading: isLoadingCommonOrgTypes, - isError: isErrorCommonOrgTypes, - refetch: refetchCommonOrgTypes, - } = useQuery({ - queryKey: ["position-types-common-org", organizationId, params], - queryFn: async () => { - if (!organizationId) return undefined; - const res = await positionTypeService.getCommonTypesByOrganizationId(organizationId, params); - return res.data as PositionTypesListResponse | undefined; - }, - enabled: !!organizationId, - }); - // Common types with unit ID (includes both unit-specific and common types) const { data: commonPositionTypes, @@ -123,15 +108,16 @@ export const usePositionTypes = ({ mutationFn: (payload: CreatePositionTypePayload) => positionTypeService.create(payload), onSuccess: () => { - toast.success("Position type created"); - queryClient.invalidateQueries({ queryKey: ["position-types"] }); + toast.success(t("contentManagement.positionTypeCreated")); + invalidateAll(); }, onError: (error) => { handleError(error); }, }); - // Update + // Update. IAM answers 403 `position_type_not_allowed_to_update` for built-in + // (isSystem) types, so the error has to reach the user. const updatePositionType = useMutation({ mutationFn: ({ id, @@ -141,11 +127,12 @@ export const usePositionTypes = ({ data: UpdatePositionTypePayload; }) => positionTypeService.update(id, data), onSuccess: () => { - toast.success("Position type updated"); - queryClient.invalidateQueries({ queryKey: ["position-types"] }); - queryClient.invalidateQueries({ queryKey: ["position-type", id] }); + toast.success(t("contentManagement.positionTypeUpdated")); + invalidateAll(); + }, + onError: (error) => { + handleError(error); }, - onError: () => {}, }); //update positon from to @@ -153,30 +140,32 @@ export const usePositionTypes = ({ mutationFn: ({ toId, fromId }: { toId: string; fromId: string }) => positionTypeService.updateFromto(toId, fromId), onSuccess: () => { - toast.success("Position type migration updated"); - queryClient.invalidateQueries({ queryKey: ["position-types-to"] }); - queryClient.invalidateQueries({ queryKey: ["position-type", id] }); + toast.success(t("contentManagement.positionTypeMigrated")); + invalidateAll(); + }, + onError: (error) => { + handleError(error); }, - onError: () => {}, }); //update all postions const migratePositionsByPositions = useMutation({ mutationFn: ({ id, data }: { id: string; data: PositionRequest }) => positionTypeService.updateByPostion(id, data), onSuccess: () => { - toast.success("Position type migration updated"); - queryClient.invalidateQueries({ queryKey: ["position-types-migration"] }); - queryClient.invalidateQueries({ queryKey: ["position-type", id] }); + toast.success(t("contentManagement.positionTypeMigrated")); + invalidateAll(); + }, + onError: (error) => { + handleError(error); }, - onError: () => {}, }); - // Delete + // Delete. Also 403s for built-in types. const deletePositionType = useMutation({ mutationFn: (id: string) => positionTypeService.delete(id), onSuccess: () => { - toast.success("Position type deleted"); - queryClient.invalidateQueries({ queryKey: ["position-types"] }); + toast.success(t("contentManagement.positionTypeDeleted")); + invalidateAll(); }, onError: (error) => { handleError(error); @@ -205,16 +194,6 @@ export const usePositionTypes = ({ refetchPosition, isErrorPosition, isLoadingPosition, - // organization-based position types - positionTypeByOrgId, - refetchOrgPosition, - isErrorOrgPosition, - isLoadingOrgPosition, - // common types with organization ID - commonPositionTypesByOrgId, - refetchCommonOrgTypes, - isErrorCommonOrgTypes, - isLoadingCommonOrgTypes, // common types with unit ID commonPositionTypes: commonPositionTypes?.items ?? [], isLoadingCommonTypes, diff --git a/apps/edr-freight-web/backoffice/src/user-management/services/api/positionTypesService.ts b/apps/edr-freight-web/backoffice/src/user-management/services/api/positionTypesService.ts index b3ac43ecd..450009fa7 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/services/api/positionTypesService.ts +++ b/apps/edr-freight-web/backoffice/src/user-management/services/api/positionTypesService.ts @@ -40,38 +40,25 @@ export const positionTypeService = { getById: (id: string): Promise> => axiosInstance.get(`/position-types/${id}`, { headers: withHeaders() }), + // Types owned by one unit. IAM has no organization-scoped route — position + // types carry a unitId only, so scoping to an org means filtering by that + // org's units client-side. getByUnitId: ( - id: string, + unitId: string, params?: Params, ): Promise> => - axiosInstance.get(`/position-types/list/${id}`, { - headers: withHeaders(), - params, - }), - - getByOrganizationId: ( - id: string, - params?: Params, - ): Promise> => - axiosInstance.get(`/position-types/list/${id}`, { - headers: withHeaders(), - params, - }), - - getCommonTypesByOrganizationId: ( - id: string, - params?: Params, - ): Promise> => - axiosInstance.get(`/position-types/list-with-commons/${id}`, { + axiosInstance.get(`/position-types/list/${unitId}`, { headers: withHeaders(), params, }), + // WHERE isSystem = true OR unitId = :unitId — "commons" means the built-in + // types, not the ones with a null unitId. getCommonTypesById: ( - id: string, + unitId: string, params: Params, ): Promise> => - axiosInstance.get(`/position-types/list-with-commons/${id}`, { + axiosInstance.get(`/position-types/list-with-commons/${unitId}`, { headers: withHeaders(), params, }), From dcc94643d0c8ee7a2ba72379c8568f2ad617ddad Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 23 Jul 2026 13:48:42 +0000 Subject: [PATCH 3/6] feat(companies): enforce customers:* permissions on customer endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The customers:* keys were seeded and present in the backoffice constants but enforced nowhere except reset-password. Customer CRUD sat behind the coarse edr_freight_app:admin umbrella, and every company read endpoint was unguarded. Two routes could not be gated on the route alone, because the authority they need depends on the request BODY, not the path: - PATCH /companies/:id carries `status` (UpdateCompanyDto extends PartialType(CreateCompanyDto)), so it both edits fields and blacklists. - PATCH /company-profiles/:profileId/status is approve, reject, suspend and blacklist on one route. Both now take a one-of route guard and assert per-status against a shared STATUS_PERM map: approving/reactivating needs customers:verify, suspending or blacklisting needs customers:deactivate. PATCH /companies/:id additionally requires customers:update when any non-status field is present, so a caller holding only deactivate cannot rename a company. The backoffice mirrors the same map so no button is offered that the server would reject. GET /companies/:companyId/documents is left authenticated-only with the split in the handler: it is dual-audience. The portal reads its own documents during onboarding, and the contract-request detail page (gated on contracts:view) reads the applicant's. Gating it on customers:view alone would have 403'd customers on their own documents and blanked the contract reviewer's panel. The two by-company customer-view reads take a one-of guard for the same reason — otherwise a staffer granted only customers:view gets a detail page whose tabs 403 individually. Frontend: the customers routes were sidebar-filtered but not wrapped in RequirePermission, so direct URL navigation rendered them for anyone. Verified: freight-api type-check clean; backoffice type-check unchanged from HEAD (pre-existing errors only); 25 tests pass across the companies and freight-permission suites. Not exercised against a running API. Co-Authored-By: Claude Opus 4.8 --- .../modules/bookings/bookings.controller.ts | 5 +- .../modules/companies/companies.controller.ts | 109 +++++++++++++++-- .../src/modules/payment/payment.controller.ts | 5 +- apps/edr-freight-web/backoffice/src/App.tsx | 18 ++- .../customers/ChangeRequestReview.tsx | 47 +++++--- .../src/components/customers/badges.tsx | 110 +++++++++++------- .../pages/customers/CustomerDetailPage.tsx | 12 +- 7 files changed, 231 insertions(+), 75 deletions(-) diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index c93c61f13..c292a0395 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -252,8 +252,11 @@ export class BookingsController { return this.bookingsService.findAll(filter, companyId); } + // Powers the customer-detail bookings tab, so `customers:view` reaches it too + // — otherwise a staffer granted only the customer permission gets a page whose + // tabs 403 individually. @Get("by-company/:companyId/customer-view") - @BookingView() + @BookingStaff([FREIGHT_PERMS.customers.view, FREIGHT_PERMS.bookings.view]) @ApiOperation({ summary: "List bookings for a company (customer-view shape, backoffice)", }) diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index 43d70ce19..8e3be4d1a 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -11,13 +11,22 @@ import { HttpCode, HttpStatus, UseInterceptors, + UseGuards, UploadedFiles, BadRequestException, + NotFoundException, } from "@nestjs/common"; import { AnyFilesInterceptor } from "@nestjs/platform-express"; import { ApiOperation, ApiTags, ApiConsumes } from "@nestjs/swagger"; import { CurrentUser } from "@edr/api-common"; -import { FreightAdmin } from "../../common/booking-guards"; +import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; +import { JwtGuard } from "@tria-plc/api-common/modules/auth/services/jwt.guard"; +import { BookingStaff } from "../../common/booking-guards"; +import { + assertFreightPermission, + hasFreightPermission, +} from "../../common/freight-permission.util"; +import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; import { FilesService } from "../files/files.service"; import { CompaniesService } from "./companies.service"; import { CreateCompanyDto } from "./dto/create-company.dto"; @@ -59,6 +68,23 @@ interface CurrentIamUser { phoneNumber?: string; } +/** + * Which permission a status write needs. Approving/reactivating is a different + * authority from suspending, but both arrive on the same route with the target + * in the BODY — a route-level guard can't tell them apart, so the handlers + * assert against this map instead. + * + * Keyed by string so it serves both `CompanyStatus` and `ProfileStatus` + * (a superset: it adds `rejected`). + */ +const STATUS_PERM: Record = { + active: FREIGHT_PERMS.customers.verify, + pending: FREIGHT_PERMS.customers.verify, + rejected: FREIGHT_PERMS.customers.verify, + suspended: FREIGHT_PERMS.customers.deactivate, + blacklisted: FREIGHT_PERMS.customers.deactivate, +}; + @ApiTags("Companies") @Controller("companies") export class CompaniesController { @@ -410,7 +436,7 @@ export class CompaniesController { // Used by backoffice @Post() - @FreightAdmin() + @BookingStaff(FREIGHT_PERMS.customers.create) @ApiOperation({ summary: "Create a new company (customer, freight_forwarder, dj_freight_forwarder, transporter)", @@ -421,12 +447,14 @@ export class CompaniesController { } @Get("stats") + @BookingStaff(FREIGHT_PERMS.customers.view) @ApiOperation({ summary: "Company counts by status (KPI strip)" }) async getStats(): Promise { return this.companiesService.getCompanyStats(); } @Get() + @BookingStaff(FREIGHT_PERMS.customers.view) @ApiOperation({ summary: "List companies (paginated, filterable)" }) async findAll( @Query() query: ListCompaniesQueryDto, @@ -436,6 +464,7 @@ export class CompaniesController { } @Get(":id") + @BookingStaff(FREIGHT_PERMS.customers.view) @ApiOperation({ summary: "Get company by ID" }) async findById( @Param("id", ParseUUIDPipe) id: string, @@ -446,30 +475,77 @@ export class CompaniesController { return dto; } + /** + * Edits fields AND carries `status`, so it spans two authorities. The route + * guard is one-of (a status-only caller must get in); the asserts below are + * what actually authorize: touching `status` needs the permission + * {@link STATUS_PERM} maps it to, touching anything else needs + * `customers:update`. Both checks are required — without the second, a + * caller holding only `customers:deactivate` could rename the company. + */ @Patch(":id") - @FreightAdmin() + @BookingStaff([ + FREIGHT_PERMS.customers.update, + FREIGHT_PERMS.customers.verify, + FREIGHT_PERMS.customers.deactivate, + ]) @ApiOperation({ summary: "Update a company" }) async update( @Param("id", ParseUUIDPipe) id: string, @Body() dto: UpdateCompanyDto, + @CurrentUser() user: TCurrentUser, ): Promise { + const { status, ...fields } = dto; + if (status) assertFreightPermission(user, STATUS_PERM[status]); + if (Object.keys(fields).length > 0) { + assertFreightPermission(user, FREIGHT_PERMS.customers.update); + } const company = await this.companiesService.updateCompany(id, dto); return new ResponseCompanyDto(company); } @Delete(":id") - @FreightAdmin() + @BookingStaff(FREIGHT_PERMS.customers.deactivate) @ApiOperation({ summary: "Soft-delete a company" }) @HttpCode(HttpStatus.NO_CONTENT) async remove(@Param("id", ParseUUIDPipe) id: string): Promise { await this.companiesService.deleteCompany(id); } + /** + * Dual-audience: staff read any customer's documents, and the portal reads + * its OWN during onboarding (`companiesService.getDocuments`). So the route + * is authenticated-only and the split happens here — same shape as + * `GET /contracts/:id`. Gating it on a staff permission alone would 403 every + * customer on their own documents. + * + * The staff arm is one-of because two pages consume it: the customer detail + * page (`customers:view`) and the contract-request detail page, whose route + * is gated on `contracts:view` — a contract reviewer without the customer + * permission still needs the applicant's documents. + */ @Get(":companyId/documents") + @UseGuards(JwtGuard) @ApiOperation({ summary: "List documents uploaded for a company" }) async listDocuments( @Param("companyId", ParseUUIDPipe) companyId: string, + @CurrentUser() user: TCurrentUser, ) { + const isStaff = [ + FREIGHT_PERMS.customers.view, + FREIGHT_PERMS.contracts.view, + FREIGHT_PERMS.bookings.view, + ].some((p) => hasFreightPermission(user, p)); + + if (!isStaff) { + const { company } = await this.companiesService.getCompanyInfoByUserId( + user.id, + ); + // Hidden as NotFound rather than Forbidden so company ids can't be probed. + if (company.id !== companyId) { + throw new NotFoundException(`Company ${companyId} not found`); + } + } const files = await this.filesService.findByResource(companyId, "companies"); return Promise.all( files.map(async (f) => ({ @@ -490,7 +566,7 @@ export class CompaniesController { } @Post("documents/:fileId/request-change") - @FreightAdmin() + @BookingStaff(FREIGHT_PERMS.customers.verify) @ApiOperation({ summary: "Ask the customer to correct one uploaded document", description: @@ -532,14 +608,23 @@ export class CompaniesController { return this.companiesService.uploadCompanyDocuments(companyId, files, user.id); } + /** + * Approve / reject / suspend / blacklist all arrive here with the target in + * the body, so authorization is per-status via {@link STATUS_PERM} rather + * than on the route (the guard is only the one-of gate). + */ @Patch("company-profiles/:profileId/status") - @FreightAdmin() + @BookingStaff([ + FREIGHT_PERMS.customers.verify, + FREIGHT_PERMS.customers.deactivate, + ]) @ApiOperation({ summary: "Update a company profile's approval status" }) async updateCompanyProfileStatus( - @CurrentUser() user: CurrentIamUser, + @CurrentUser() user: TCurrentUser, @Param("profileId", ParseUUIDPipe) profileId: string, @Body() dto: UpdateCompanyProfileStatusDto, ): Promise { + assertFreightPermission(user, STATUS_PERM[dto.status]); const profile = await this.companiesService.setCompanyProfileStatus( profileId, dto.status, @@ -550,7 +635,7 @@ export class CompaniesController { } @Get(":companyId/change-requests") - @FreightAdmin() + @BookingStaff(FREIGHT_PERMS.customers.view) @ApiOperation({ summary: "List a company's profile change requests" }) async listChangeRequests( @Param("companyId", ParseUUIDPipe) companyId: string, @@ -560,7 +645,7 @@ export class CompaniesController { } @Post("change-requests/:id/approve") - @FreightAdmin() + @BookingStaff(FREIGHT_PERMS.customers.verify) @ApiOperation({ summary: "Approve a pending profile change request (applies the changes)", }) @@ -576,7 +661,7 @@ export class CompaniesController { } @Post("change-requests/:id/reject") - @FreightAdmin() + @BookingStaff(FREIGHT_PERMS.customers.verify) @ApiOperation({ summary: "Reject a pending profile change request with a note", }) @@ -594,7 +679,7 @@ export class CompaniesController { } @Post(":companyId/profiles") - @FreightAdmin() + @BookingStaff(FREIGHT_PERMS.customers.update) @ApiOperation({ summary: "Add a profile (employee) to a company" }) async createProfile( @Param("companyId", ParseUUIDPipe) companyId: string, @@ -608,6 +693,7 @@ export class CompaniesController { } @Get(":companyId/profiles") + @BookingStaff(FREIGHT_PERMS.customers.view) @ApiOperation({ summary: "List profiles for a company" }) async listProfiles( @Param("companyId", ParseUUIDPipe) companyId: string, @@ -618,6 +704,7 @@ export class CompaniesController { } @Get("profile/user/:userId") + @BookingStaff(FREIGHT_PERMS.customers.view) @ApiOperation({ summary: "Get profile by IAM user ID" }) async findProfileByUser( @Param("userId", ParseUUIDPipe) userId: string, diff --git a/apps/edr-freight-api/src/modules/payment/payment.controller.ts b/apps/edr-freight-api/src/modules/payment/payment.controller.ts index 50856c3d7..2ae62d4f3 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.controller.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.controller.ts @@ -16,7 +16,8 @@ import { } from "@nestjs/swagger"; import { Response } from "express"; import { Public } from "@edr/api-common"; -import { BookingView } from "../../common/booking-guards"; +import { BookingStaff, BookingView } from "../../common/booking-guards"; +import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; import { PaymentService } from "./payment.service"; import { IntentStatusDto } from "./payments.dto"; @@ -25,7 +26,9 @@ import { IntentStatusDto } from "./payments.dto"; export class PaymentController { constructor(private readonly paymentService: PaymentService) { } + // Customer-detail payments tab — same one-of rule as the bookings tab. @Get("by-company/:companyId/customer-view") + @BookingStaff([FREIGHT_PERMS.customers.view, FREIGHT_PERMS.payments.view]) @ApiOperation({ summary: "List payments for a company (customer-view shape, backoffice)" }) findByCompanyCustomerView( @Param("companyId", ParseUUIDPipe) companyId: string, diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 04a26e3fe..bb4d024c8 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -802,8 +802,22 @@ const App = () => { } /> } /> - } /> - } /> + + + + } + /> + + + + } + /> )} - - - - + {/* Reviewing the diff is `customers:view`; deciding on it is + `customers:verify`. Without it the request stays readable but + un-actionable. */} + {canReview && ( + + + + + )} )} diff --git a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx index 04267cc7b..112026ca6 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx +++ b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx @@ -11,6 +11,8 @@ import { } from "@mantine/core"; import { useMutation } from "@tanstack/react-query"; import { useState } from "react"; +import { useAuth } from "@/auth/useAuth"; +import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import { api } from "@/services/api"; import type { @@ -286,6 +288,19 @@ export function InvoiceStatusBadge({ * regardless (setCompanyProfileStatus). Suspend/blacklist/reinstate stay live so * an already-active profile is still managable. */ +/** + * Which permission each status write needs. Mirrors `STATUS_PERM` in the API's + * `companies.controller.ts` — approving is a different authority from + * suspending, and both go through the same endpoint. Keep the two in step. + */ +const STATUS_PERM: Record = { + active: FREIGHT_PERMS.customers.verify, + pending: FREIGHT_PERMS.customers.verify, + rejected: FREIGHT_PERMS.customers.verify, + suspended: FREIGHT_PERMS.customers.deactivate, + blacklisted: FREIGHT_PERMS.customers.deactivate, +}; + export function ProfileApprovalActions({ profileId, status, @@ -295,6 +310,10 @@ export function ProfileApprovalActions({ status: ProfileStatus; locked?: boolean; }) { + const { user } = useAuth(); + /** The API rejects these anyway — hide rather than offer a button that 403s. */ + const canSet = (next: ProfileStatus) => + hasPermission(user, STATUS_PERM[next]); const { mutate, isPending } = useMutation( api.customers.setProfileStatus.mutationOptions(), ); @@ -414,35 +433,41 @@ export function ProfileApprovalActions({ } if (status === "pending") { + if (!canSet("active") && !canSet("rejected")) return null; return ( <> {decisionModal} - - + {canSet("active") && ( + + )} + {canSet("rejected") && ( + + )} ); } if (status === "rejected") { + if (!canSet("active")) return null; return ( - + {canSet("active") && ( + + )} + {canSet("blacklisted") && ( + + )} ); } if (status === "blacklisted") { + if (!canSet("pending")) return null; return ( - - - - ); -} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx index f6c3098a0..aff1526a6 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx @@ -72,7 +72,6 @@ import { ContractClearancePanel } from "./ContractClearancePanel"; import { ContractClearanceWorkflowBanner } from "./ContractClearanceWorkflowBanner"; import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel"; import { InitiateBookingButton } from "@/components/customer-actions/ContractCustomerAction"; -import { PayClearanceFeeButton } from "@/pages/bookings/payments/PayClearanceFeeButton"; import { formatRateUnit } from "./new-contract-form/unit-rates"; import { getContractBookingAction } from "./contract-booking-action"; import { closedWindowMessage, hasOpenWindow } from "./booking-window"; @@ -438,9 +437,6 @@ export default function ContractDetailPage() { // clearance is finalized. const canUploadClearance = CLEARANCE_UPLOAD_STATUSES.includes(contract.status) && !clearanceFinalized; - // Prepaid clearance service fee gate (Path B) — the document step stays - // locked until the fee invoice settles. - const awaitingClearanceFee = contract.status === "AWAITING_CLEARANCE_PAYMENT"; return ( @@ -574,13 +570,6 @@ export default function ContractDetailPage() { Global Logistics is creating your booking )} - {awaitingClearanceFee && ( - - )} {canUploadClearance && ( + + + + {/* Step 0 — Setup: operation, contract, service, currency, miles. */} {step === 0 && ( diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/total.ts b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/total.ts index 841d7ddf1..b4055bb1c 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/total.ts +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/total.ts @@ -111,7 +111,10 @@ export function computeShipmentTotal( const qty = Number(values.cargoWeightTons || values.itemCount || 0); const rate = rateFor( - (i) => (i.unit === "per_ton" || i.unit === "per_item") && !i.isClearance, + (i) => + (i.unit === "per_ton" || i.unit === "per_item") && + !i.isClearance && + !i.conditionalOn, ) ?? items[0]; if (rate && qty > 0) { lines.push({ @@ -150,6 +153,23 @@ export function computeShipmentTotal( } } + // Lashing / cargo securing — bulk-only, applies whenever the contract shows + // it (the commodity needs lashing). Per-ton scales by tonnage; per-wagon + // depends on the wagon capacity the train stocks — shown at real pricing. + const lashing = items.find((i) => i.conditionalOn === "has_lashing"); + if (lashing && lashing.unit === "per_ton") { + const tons = Number(values.cargoWeightTons || 0); + if (tons > 0) { + lines.push({ + label: lashing.label, + unitPrice: lashing.unitPrice, + unit: lashing.unit, + quantity: tons, + amount: lashing.unitPrice * tons, + }); + } + } + // Customs clearance service fee — billed on the booking invoice with the // freight. Container fees estimate per size (per box, or per wagon: two 20ft // share one); bulk per-ton scales by tonnage. Bulk per-wagon fees depend on From 40f16f3cec1d88085954a7a3651616e78974d94a Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 23 Jul 2026 14:30:49 +0000 Subject: [PATCH 6/6] changes --- .../portal/src/pages/contracts/ContractDetailPage.tsx | 4 ++-- .../portal/src/pages/contracts/NewContractPage.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx index aff1526a6..fe9209c1d 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx @@ -924,8 +924,8 @@ export default function ContractDetailPage() { )} {item.isClearance && ( - Paid in advance, before clearance — excluded from - shipment invoices + Customs service fee — billed on your shipment booking + invoice together with the freight )} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx index 81e3a1806..27a1c242f 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx @@ -993,8 +993,8 @@ export default function NewContractPage({ )} {item.isClearance && ( - Paid in advance, before clearance — not part of your - shipment booking invoice + Customs service fee — billed on your shipment booking + invoice together with the freight )}